Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
c412b58
Fix WIRE-385 stale outbound operator rosters
huangminghuang Sep 7, 2026
6db626e
Merge remote-tracking branch 'origin/master' into fix/wire-385-stale-…
huangminghuang Sep 10, 2026
902073f
Address WIRE-385 review feedback
huangminghuang Sep 10, 2026
4cf466f
Regenerate epoch contract artifact
huangminghuang Sep 10, 2026
db12fc5
Merge remote-tracking branch 'origin/master' into fix/wire-385-stale-…
huangminghuang Sep 11, 2026
22bf9d1
Fix WIRE-385 withheld schedule recovery
huangminghuang Sep 11, 2026
31d41cb
Merge remote-tracking branch 'origin/master' into fix/wire-385-stale-…
huangminghuang Sep 11, 2026
5d6f8e0
Merge remote-tracking branch 'origin/master' into fix/wire-385-stale-…
huangminghuang Sep 11, 2026
de54feb
Regenerate dependent SYSIO message channel artifact
huangminghuang Sep 11, 2026
6023cb4
Simplify WIRE-385 schedule publication and activation
huangminghuang Sep 11, 2026
ba6232d
Refresh Solana deposit IDL test fixture
huangminghuang Sep 11, 2026
eb502be
Merge remote-tracking branch 'origin/master' into fix/wire-385-stale-…
huangminghuang Sep 12, 2026
2ca8d89
Simplify schedule repair and test consensus recovery
huangminghuang Sep 12, 2026
17b8adc
Merge remote-tracking branch 'origin/master' into fix/wire-385-stale-…
huangminghuang Sep 15, 2026
9bd7140
Address PR review feedback
huangminghuang Sep 15, 2026
5754ab7
Fix held-duty audit marker timing
huangminghuang Sep 15, 2026
3b6aa34
Merge current SYSIO master into WIRE-385 branch
huangminghuang Sep 21, 2026
3227cbb
Merge current SYSIO master into WIRE-385 branch
huangminghuang Sep 21, 2026
813957f
Re-anchor held operator duty on outposts
huangminghuang Sep 21, 2026
d97e4ad
Merge current SYSIO master into WIRE-385 branch
huangminghuang Sep 22, 2026
a9a4c51
Restore ordinary held-duty termination accounting
huangminghuang Sep 22, 2026
83b662d
Limit WIRE-385 to post-mutation roster publication
huangminghuang Sep 22, 2026
84fcd19
Merge remote-tracking branch 'origin/master' into fix/wire-385-stale-…
huangminghuang Sep 24, 2026
fce6321
Correct epoch continuation diagnostics and comments
huangminghuang Sep 24, 2026
dce1c13
Rebuild epoch artifact with corrected continuation diagnostics
huangminghuang Sep 24, 2026
67fe61c
Merge master and regenerate reviewed epoch contract
huangminghuang Sep 25, 2026
e66733a
Merge master envelope retention into WIRE-385
huangminghuang Sep 26, 2026
f4437d2
Merge current master query coverage into WIRE-385
huangminghuang Sep 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ namespace sysio {
[[sysio::action]]
void advance();

/// Internal continuation after epoch-close operator mutations execute.
[[sysio::action]]
void finishadv(uint32_t epoch_index, int64_t emission_amount);

/// Group assignment — reads AVAILABLE batch ops from sysio.opreg.
[[sysio::action]]
void schbatchgps();
Expand Down
139 changes: 79 additions & 60 deletions contracts/sysio.epoch/src/sysio.epoch.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
#include <sysio.chains/sysio.chains.hpp>
#include <sysio/opp/attestations/attestations.pb.hpp>
#include <magic_enum/magic_enum.hpp>
#include <algorithm>

namespace sysio {

Expand All @@ -45,6 +46,7 @@ namespace {

constexpr name SYSTEM_ACCOUNT = "sysio"_n;
constexpr name TOKEN_ACCOUNT = "sysio.token"_n;
constexpr name FINISH_ADVANCE = "finishadv"_n;

/// Action identifiers owned by sysio.chalg and invoked by epoch close.
namespace chalg_actions {
Expand Down Expand Up @@ -594,9 +596,9 @@ void epoch::advance() {
//
// Invariant — no cross-epoch double slash: opreg::slash THROWS on an already-SLASHED operator,
// which would abort advance and stall OPP epoch advancement. These inline slashes execute only
// after advance returns, so the schedule slide below can temporarily place a just-slashed
// operator in its new tail while the operator still reads ACTIVE. That member cannot create a
// later non-canonical observation: sysio.msgch::deliver requires its current sysio.opreg status
// after advance returns. The finishadv continuation waits for those mutations before
// selecting the new tail. A removed member cannot create a later non-canonical
// observation: sysio.msgch::deliver requires its current sysio.opreg status
// to be ACTIVE before accepting delivery. Once the slash has executed, the scheduled SLASHED
// member cannot deliver or be queued for another non-canonical-delivery slash. The collection
// above also deduplicates multiple non-canonical observations for one member in this advance.
Expand All @@ -612,9 +614,9 @@ void epoch::advance() {
).send();
}

// Preserve the delivery history after slashing. A non-canonical operator is already
// SLASHED here, so opreg::termcheck returns without converting the punitive outcome into a
// termination/remit. Other group members retain their normal delivery accounting.
// Preserve delivery history and ordinary termination accounting. Inline
// slashing completes before these actions, and all mutations complete
// before finishadv reads the registry for outbound attestations.
for (const auto& observation : observations) {
action(
permission_level{get_self(), "owner"_n},
Expand All @@ -634,13 +636,71 @@ void epoch::advance() {
// erases them once they fall out of its retention window.
}

const bool had_expiring_group = state.current_epoch_index > 0;

state.current_epoch_index++;
state.current_epoch_start =
(state.next_epoch_start.sec_since_epoch() == 0) ? now : state.next_epoch_start;
state.next_epoch_start = state.current_epoch_start +
microseconds(static_cast<int64_t>(cfg.epoch_duration_sec) * 1'000'000);
state_tbl.set(state, ram_payer);

// Withdrawal flushing can also change eligibility. Its nested callbacks
// must complete before schedule selection and roster serialization.
// Inline siblings execute their complete subtrees in order, atomically.
action(
permission_level{get_self(), "owner"_n},
OPREG_ACCOUNT,
"flushwtdw"_n,
std::make_tuple(state.current_epoch_index)
).send();

// Keep the refund subtree at its original depth; refundwire can itself
// transfer a fee or sweep expired claims. The state write above makes the
// new epoch index visible before this action runs, and the action finishes
// before roster publication. Each row queued via
// `sysio.uwrit::swapfromwire` since the last advance is re-validated
// (target reserve ACTIVE + public, variance) and either becomes a
// PENDING uwreq for the single-leg underwriter race or is refunded.
// Runs before `buildenv` so this epoch's envelopes reflect any state
// the drain produced; never throws (refund-and-drop semantics).
action(
permission_level{get_self(), "owner"_n},
UWRIT_ACCOUNT,
"drainfwq"_n,
std::make_tuple()
).send();

action(
permission_level{get_self(), "owner"_n},
get_self(),
FINISH_ADVANCE,
std::make_tuple(state.current_epoch_index, gate.emission_amount)
).send();

// Preserve payout depth: finishadv and its accrual/history descendants
// complete before this sibling executes.
if (gate.is_pay_epoch) {
action(
permission_level{get_self(), "owner"_n},
SYSTEM_ACCOUNT,
"payepoch"_n,
std::make_tuple(
state.current_epoch_index,
std::vector<std::vector<name>>{},
gate.period_emission
)
).send();
}
}

void epoch::finishadv(uint32_t epoch_index, int64_t emission_amount) {
require_auth(get_self());
check(get_sender() == get_self(), "finishadv must be sent inline by sysio.epoch");
epochcfg_t cfg_tbl(get_self());
const auto cfg = cfg_tbl.get();
epochstate_t state_tbl(get_self());
auto state = state_tbl.get();
check(state.current_epoch_index == epoch_index, "finishadv epoch mismatch");
const bool had_expiring_group = epoch_index > 1;

// ── Slide the schedule window ───────────────────────────────────────────
// Skip on the genesis advance (0 → 1): schbatchgps just placed
Expand Down Expand Up @@ -723,7 +783,7 @@ void epoch::advance() {
// below). Short-but-non-empty is pre-existing behaviour and is not made
// safe here -- it is reported so the roster can be repaired off-chain.
if (new_tail.size() < cfg.operators_per_epoch) {
sysio::print("sysio.epoch::advance: only ", new_tail.size(), " of ",
sysio::print("sysio.epoch::finishadv: only ", new_tail.size(), " of ",
cfg.operators_per_epoch,
" eligible batch operators for the new tail group at epoch ",
state.current_epoch_index + cfg.batch_op_groups - 1,
Expand All @@ -748,21 +808,6 @@ void epoch::advance() {

state_tbl.set(state, ram_payer);

// Drain matured rows from `sysio.opreg::wtdwqueue`. Operators that queued
// a withdrawal at least WITHDRAW_WAIT_EPOCHS ago are now eligible — opreg
// subtracts from the balance and emits OPERATOR_ACTION(WITHDRAW_REMIT) to
// the matching outpost (or, for WIRE-direct withdraws, CREDITS the operator's
// `sysio.opreg::remitclaims` row, which it pulls with `claimremit` — nothing
// is transferred from this path, precisely because it runs inline from here).
// Slashed-during-the-wait rows are dropped silently inside
// opreg's flushwtdw. See CLAUDE-WIRE-OPERATOR-COLLATERAL-IMPL-PLAN.md §3.3.
action(
permission_level{get_self(), "owner"_n},
OPREG_ACCOUNT,
"flushwtdw"_n,
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
Expand Down Expand Up @@ -852,14 +897,14 @@ void epoch::advance() {
//
// Only the ATTESTATION looks ahead. The depot's own schedule state is
// untouched -- `current_batch_op_group` still names the group on duty NOW,
// and `advance` still slides the window so the front is the current epoch.
// and `finishadv` still slides the window so the front is the current epoch.
// Nothing that reads `epoch_state` changes meaning.
//
// `epoch_index` stays the epoch this envelope IS for; it identifies the
// envelope, not the roster, and no outpost reads it.
{
opp::attestations::BatchOperatorGroups attest;
// The window SLIDES; it does not rotate. `advance` erases the front and
// The window SLIDES; it does not rotate. `finishadv` erases the front and
// pushes a new tail, and every write to the cursor pins it to 0 (here,
// and `schbatchgps`) -- so the group on duty NEXT is simply the one
// after the cursor.
Expand All @@ -873,7 +918,7 @@ void epoch::advance() {
// The bound check is also what keeps an EMPTY schedule off a division.
// `group_count == 0` is reachable here: the slide above is guarded by
// `!empty()`, but nothing requires a seated schedule before this block,
// and `% 0` is an `i32.rem_u` trap that would abort `advance` and halt
// and `% 0` is an `i32.rem_u` trap that would abort `finishadv` and halt
// epoch advancement chain-wide.
//
// Falling back to the cursor covers the single-group case: the same
Expand Down Expand Up @@ -917,12 +962,12 @@ void epoch::advance() {
// carrying an empty group regardless -- so it buys nothing here.
//
// Withheld by SKIPPING THE QUEUEOUT ONLY -- never by returning from
// `advance`, which still has the epoch's remaining attestations and
// `finishadv`, which still has the epoch's remaining attestations and
// actions to issue after this block.
const bool 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 ",
sysio::print("sysio.epoch::finishadv: no non-empty next group to publish at epoch ",
state.current_epoch_index,
" (groups=", group_count, ", next_index=", next_group_index,
"); withholding BatchOperatorGroups -- outposts retain their "
Expand Down Expand Up @@ -951,7 +996,7 @@ void epoch::advance() {
auto out = zpp::bits::out{encoded, zpp::bits::no_size{}};
(void)out(attest);

// `have_next_group` gates the QUEUEOUT, not `advance` -- see above.
// `have_next_group` gates the QUEUEOUT, not `finishadv` -- 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) {
Expand All @@ -970,19 +1015,6 @@ void epoch::advance() {
}
}

// Drain the swap-from-WIRE queue: each row queued via
// `sysio.uwrit::swapfromwire` since the last advance is re-validated
// (target reserve ACTIVE + public, variance) and either becomes a
// PENDING uwreq for the single-leg underwriter race or is refunded.
// Runs before `buildenv` so this epoch's envelopes reflect any state
// the drain produced; never throws (refund-and-drop semantics).
action(
permission_level{get_self(), "owner"_n},
UWRIT_ACCOUNT,
"drainfwq"_n,
std::make_tuple()
).send();

// Build outbound envelopes for each outpost
{
sysio::chains::chains_t chains_tbl(CHAINS_ACCOUNT);
Expand All @@ -997,7 +1029,7 @@ void epoch::advance() {
}
}

// Emissions side. Three inline actions queued in FIFO order:
// Emissions side. Accrual and history precede advance's payout sibling:
// 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).
Expand All @@ -1006,7 +1038,7 @@ void epoch::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
// The continuation completes before that sibling; this ordering guarantees
// payepoch sees the post-accrue roster history and state.
std::vector<name> active_batch_op_members;
if (state.current_batch_op_group < state.batch_op_groups.size()) {
Expand All @@ -1020,7 +1052,7 @@ void epoch::advance() {
std::make_tuple(
state.current_epoch_index,
state.current_batch_op_group,
gate.emission_amount
emission_amount
)
).send();

Expand All @@ -1031,19 +1063,6 @@ void epoch::advance() {
std::make_tuple(state.current_epoch_index, active_batch_op_members)
).send();

if (gate.is_pay_epoch) {
action(
permission_level{get_self(), "owner"_n},
SYSTEM_ACCOUNT,
"payepoch"_n,
std::make_tuple(
state.current_epoch_index,
std::vector<std::vector<name>>{},
gate.period_emission
)
).send();
}

// No scheduled cleanup of `sysio.msgch::envelopes` is needed here:
// `deliver` prunes rows older than the previous epoch. The durable audit
// trail lives in the `envelope_log` table on the same contract, capped at
Expand All @@ -1060,7 +1079,7 @@ void epoch::advance() {
// them into N groups (`cfg.batch_op_groups`). The resulting window is
// [epoch_1_group, epoch_2_group, ..., epoch_N_group].
//
// After this, every per-epoch `advance` pops the front group and pushes
// After this, every per-epoch `finishadv` pops the front group and pushes
// a new tail group, where the tail's members are drawn from the ACTIVE
// pool MINUS anyone still resident in the N-1 surviving groups. The
// window itself encodes "scheduled in the last N-1 epochs"; no separate
Expand Down
19 changes: 19 additions & 0 deletions contracts/sysio.epoch/sysio.epoch.abi
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,20 @@
}
]
},
{
"name": "finishadv",
"base": "",
"fields": [
{
"name": "epoch_index",
"type": "uint32"
},
{
"name": "emission_amount",
"type": "int64"
}
]
},
{
"name": "pause",
"base": "",
Expand Down Expand Up @@ -169,6 +183,11 @@
"type": "advance",
"ricardian_contract": ""
},
{
"name": "finishadv",
"type": "finishadv",
"ricardian_contract": ""
},
{
"name": "pause",
"type": "pause",
Expand Down
Binary file modified contracts/sysio.epoch/sysio.epoch.wasm
Binary file not shown.
Loading
Loading