Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
68 changes: 55 additions & 13 deletions contracts/sysio.epoch/src/sysio.epoch.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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");
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<name> 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,
Expand All @@ -871,14 +906,21 @@ 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(
Comment thread
heifner marked this conversation as resolved.
permission_level{get_self(), "owner"_n},
SYSTEM_ACCOUNT,
"payepoch"_n,
std::make_tuple(
state.current_epoch_index,
state.batch_op_groups,
std::vector<std::vector<name>>{},
gate.period_emission
)
).send();
Expand Down
Binary file modified contracts/sysio.epoch/sysio.epoch.wasm
Binary file not shown.
95 changes: 69 additions & 26 deletions contracts/sysio.system/EMISSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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 |
Expand All @@ -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`).
Expand Down
Loading
Loading