Skip to content

feat(staking): replace jail liveness with on-chain block-production liveness - #489

Draft
djadjka wants to merge 6 commits into
feat/flu-989-rust-stakingfrom
feat/flu-989-port-solidity-delta
Draft

feat(staking): replace jail liveness with on-chain block-production liveness#489
djadjka wants to merge 6 commits into
feat/flu-989-rust-stakingfrom
feat/flu-989-port-solidity-delta

Conversation

@djadjka

@djadjka djadjka commented Aug 3, 2026

Copy link
Copy Markdown

Summary

Replaces the participation/felony/jail liveness tier inherited from the Solidity with a
block-production liveness tier that lives inside the staking contract itself. The external
LivenessSlashing contract is no longer called at all: the recorder, the epoch close, the verdicts
and the stipend are one atomic unit of state.

Two correctness fixes ride along, both of which the old design could not express:
committee leader weights are now frozen at commit time, and the committee-size cap is
epoch-addressed
.

Stacked on #482 (feat/flu-989-rust-staking); only contracts/staking and e2e/src/staking.rs
change.

Block-production liveness (new liveness.rs, new Fluent.storage.ProductionLiveness root)

  • recordProduction(uint64 blockNumber, uint8 leaderIndex) — system-caller-only, one call per
    block. blockNumber is an idempotency key, never an epoch tag: the epoch is derived from the
    height, so no second cursor exists that could disagree with the first.
  • Crossing an epoch boundary runs the close, in this order and with three deliberately different
    failure policies:
    1. Releases — unconditional, so an expiring exclusion can never be held hostage by the
      correlation guard. Frozen only by the kill switch.
    2. Verdicts — fail-loud. An epoch whose recorded block count != epochBlockInterval is
      tainted: it emits PartialEpoch and is not judged at all, because a partial record cannot tell
      an idle validator from a missing report. The taint is derived, not stored, so it also catches an
      executor skip and an out-of-range drop.
    3. Stipend — tolerant. It runs in a fuel-capped self-call (settleEpochStipendFrom), so a
      failing payment discards only its own frame and cannot roll back the releases and verdicts of
      the same close; the failure surfaces as StipendLegSkipped from the outer frame.
  • Verdict rule: due_i = w_i / W * recorded from the frozen weights — an expectation from
    on-chain stake, not a replay of the leader lottery. A member fails below half of due; both
    predicates are cross-multiplied, so there is no division and no fixed point. Uniform degradation
    moves every member together and fails nobody; a dead member has produced == 0 and fails at every
    dilution level. minVerdictDueBlocks is the floor below which a member holds no verdict.
  • The penalty is not a penalty on stake. A failing member gets a temporary, auto-reversing
    exclusion from committee selection on a linear ladder (kick_count episodes, capped by
    exclusionBackoffCap), measured from the current epoch so a late close cannot silently shorten it.
    Equivocation remains the only path to Jail and the only path to a seizure.
  • An exclusion is refused outright when no replacement can take the seat, and a refusal leaves
    no trace — no ladder increment, no queue entry. A small network shrinks its committee rather than
    losing quorum. Concurrency is capped at f = ⌊(n−1)/3⌋, and at most MAX_STAMPS_PER_CLOSE = 2
    stamps land per close, ordered kick-count descending then address ascending.
  • Correlation guard: more than f first-time failures in one epoch reads as a broken
    environment, not as individual faults — that close stamps nobody and emits
    CorrelatedFailureEpoch. Amnesty lasts exactly one epoch: from the second, its members are no
    longer new. Chronic failers are excluded from the "new" count, so f colluders plus one donated
    honest failure cannot hold the guard on forever.
  • Kill switch productionLivenessDisabled, seeded true at initialization — the tier ships off
    on a fresh chain, and an unwritten slot would ship it on. It freezes releases and verdicts
    together, so no exclusion expires unnoticed while the tier is off.
  • activateValidator no longer re-stamps visibility when an exclusion is running, and the release
    path is a silent no-op for a tombstoned or non-Active validator — the visibility stamp is the whole
    selection filter, so a blind re-stamp would re-seat a slashed equivocator.

Frozen leader weights

ConsensusStorage.leader_stakes is stamped at commitEpochCommittee from the selection epoch —
the same vintage that ranked membership — and is positional with epoch_committees. Computing the
weight live at read time makes it depend on the block height each node happens to read at, and the
leader is drawn from those weights: an unfrozen weight is a per-node leader split, not a rounding
error.

  • getEpochCommitteeWithStakes now returns the frozen weights and reverts with
    LeaderStakesLengthMismatch on a length mismatch. Deliberately no fallback to a live walk — that
    would restore the height-dependent read the freeze exists to remove.
  • Committee pruning clears both arrays together.
  • The stipend and the verdicts both read the same frozen vector.

Epoch-addressed committee cap

The epoch-frozen selection view stands on three epoch-addressed legs — visibility, stake, and the
cap. Reading the cap live was the missing leg: a governance change retroactively rewrote the
committee of an epoch that had already been committed.

  • ChainConfigStorage.cap_checkpoints (append-only, ascending by from_epoch) plus
    getActiveValidatorsLengthAt(uint64).
  • setActiveValidatorsLength schedules the new value from the next epoch; a repeat set inside
    the same epoch overwrites the pending tail instead of appending. The scalar getter reports the
    latest scheduled value immediately and is not epoch-correct by design.
  • ActiveValidatorsLengthChanged gains effective_epoch.

Committee selection order

selected_committee_at now takes the stake cut first and drops keyless members after, where
it previously filtered before ranking. The off-chain deriver builds its array from
getValidatorsWithKeysAt — the same selection view with inactive keys blanked — and discards the
keyless entries itself, so filtering before the cut promoted a lower-staked keyed validator and made
commitEpochCommittee reject every honest submission.

Stipend rewrite

  • Flat pro-rata over the committee's frozen leader weights, consulting no liveness verdict. The
    only exclusions are a permanent equivocation tombstone and a zero frozen weight; the partition
    special case is gone with the participation floor it depended on.
  • No more calls into the liveness contract: participation(uint64,uint32) and
    lastFinalizedEpochP1() are removed.
  • The finality gate that contract used to provide is replaced by a finished-epoch bound
    (up_to <= currentEpoch − 1). A committee may be committed two epochs ahead, so
    epoch_committees/leader_stakes exist for epochs that have not started; paying one would draw a
    full pot for zero production and advance the cursor past it irrecoverably.
  • A per-epoch belt inside settle_one skips any epoch with zero recorded blocks (stalled recorder,
    pre-activation prefix) with StipendSkipped + a zero EpochBlendRewardsCommitted. A skipped epoch
    is forfeited, not deferred — only a revert leaves it retryable.
  • MAX_SETTLE_CATCHUP lowered 32 → 4.

ABI changes (breaking)

Removed handlers: releaseValidatorFromJail(address), readmitExpiredJails(uint64),
slash(address), getFelonyThreshold/setFelonyThreshold,
getValidatorJailEpochLength/setValidatorJailEpochLength,
getParticipationFloorBps/setParticipationFloorBps,
getParticipationJailDisabled/setParticipationJailDisabled,
DEFAULT_PARTICIPATION_FLOOR_BPS, MAX_PARTICIPATION_FLOOR_BPS.

New handlers: recordProduction(uint64,uint8) 0x8244a2c2,
getProductionStats(address,uint64) 0x8e948ac1, blocksInEpoch(uint64) 0xf06be669,
producedAt(uint64,uint32) 0x91c7d453, pendingExclusions() 0xaef690f9,
readmitAtEpoch(address) 0x32066046, lastProcessedBlock() 0x33de61d2,
settleEpochStipendFrom(uint64) 0x92d321ab, getActiveValidatorsLengthAt(uint64) 0xd9b083ba,
get/setMinVerdictDueBlocks, get/setExclusionBackoffCap, get/setProductionLivenessDisabled,
DEFAULT_MIN_VERDICT_DUE_BLOCKS, DEFAULT_EXCLUSION_BACKOFF_CAP, MAX_MIN_VERDICT_DUE_BLOCKS.

Changed:

  • initialize 0x4b4b21a50xd86555fefelonyThreshold and validatorJailEpochLength are
    gone from the argument list.
  • getValidatorStatus returns 6 fields instead of 8 (slashesCount and jailedBefore dropped).
  • Errors: OnlyGovernanceContract()OnlyGovernance(), OnlyLivenessSlashing()
    OnlySelfCall(); ParticipationFloorBpsTooHigh removed; ValidatorNotInJail, StillInJail
    removed; MinVerdictDueBlocksTooHigh, LeaderStakesLengthMismatch added.
  • Events: FelonyThresholdChanged, ValidatorJailEpochLengthChanged,
    ParticipationFloorBpsChanged, ParticipationJailDisabledChanged, ValidatorReleased,
    ValidatorSlashed, LivenessJailSkippedHaltGuard removed; ProductionExclusionApplied,
    ProductionExclusionReleased, PartialEpoch, ProductionVerdictFailed,
    CorrelatedFailureEpoch, StipendLegSkipped, MinVerdictDueBlocksChanged,
    ExclusionBackoffCapChanged, ProductionLivenessDisabledChanged added.

Storage changes

  • ChainConfigStorage: drops felony_threshold, validator_jail_epoch_length,
    participation_floor_bps, participation_jail_disabled; adds cap_checkpoints,
    min_verdict_due_blocks, exclusion_backoff_cap, production_liveness_disabled.
    liveness_slashing is retained but never read — it is still required non-zero at initialization
    and the initializer selector is pinned, so removing it stays a deliberate ABI break.
  • ConsensusStorage: drops jailed_validators, jailed_scan_cursor; adds leader_stakes.
  • ValidatorStorage drops jailed_before; ValidatorSnapshotStorage drops slashes_count.
  • New ProductionLivenessStorage: last_processed_block, produced[epoch][committeeIndex],
    blocks_in_epoch, pending_exclusions, and a per-validator record
    (total_produced, last_produced_epoch_p1, last_failed_epoch_p1, readmit_at_epoch,
    kick_count) whose field order puts the two per-block counters in one slot.
  • fault_tolerance moved into math so the correlation guard, the concurrency ceiling and the
    off-chain consensus cannot disagree about f.

Tests

58 → 85 unit tests: 32 new, 5 jail-tier tests removed. New coverage includes the idempotency belt and
epoch-cursor ordering, the uncommitted-committee park, the partial-epoch taint, both kill-switch
paths, the correlation guard's newness key and its one-epoch amnesty, the per-close and concurrent
stamp bounds, refusal-leaves-no-trace, release skipping tombstoned/non-Active validators, the
activation-does-not-cancel-exclusion case, frozen-weight verdicts and stipend, the two
length-mismatch reverts, pruning both arrays, cap scheduling and same-epoch collapse, and the
self-call-only stipend re-entry.

e2e: new record_production_drives_the_epoch_close_through_real_rwasm as an independent ABI oracle
— alloy builds the calldata from its own Solidity signatures, so a drifted selector lands on
UnknownMethod() there instead of passing a unit test.

Verification

cargo test --manifest-path contracts/Cargo.toml -p fluentbase-contracts-staking   # 89 passed
cargo test -p fluentbase-e2e staking                                             # 3 passed
cargo clippy --manifest-path contracts/Cargo.toml -p fluentbase-contracts-staking --all-targets -- -D warnings   # clean

@djadjka
djadjka requested a review from dmitry123 August 3, 2026 07:41
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5992b236-7faa-43c4-bf20-1981e59d8181

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@djadjka
djadjka force-pushed the feat/flu-989-port-solidity-delta branch from 215f1fb to d124d88 Compare August 3, 2026 07:51
djadjka added 6 commits August 4, 2026 12:26
A Solidity array puts a fixed-size slot per element at the front of the
encoding (the "head"), then the variable-size parts after it. The three
places that had to agree on how wide that slot is disagreed:

  allocation  32 bytes per element
  writing     ALIGN.max(T::HEADER_SIZE) per element
  reading     align_up(T::HEADER_SIZE) per element

For a struct with a `bytes` field, HEADER_SIZE is 72 (the sum of the
field header sizes), so writing strode 72 while the head only had 32 per
element. Element 1 was written on top of element 0's data.

The damage depends on the element type and how many there are. With a
dynamic element it starts at two: the second element's slot is never
written and the first element's last field is overwritten. With three it
scrambles, with four it overruns the buffer. Decoding an array produced
by a correct encoder panicked. A static element wider than one word only
breaks from three elements up, because the struct's own encoder rounds
the bad offset up and that happens to land right for one and two.

Nothing caught it because every existing check used a one-element array,
where the stride is never applied. That includes the reference vector
checked against `cast abi-encode`.

Replace all three with one rule, since the ABI has exactly two cases: a
dynamic element gets one 32-byte offset word, a static element is stored
inline and gets its aligned size. HEADER_SIZE is not the slot width for a
dynamic element and must not be used as one.

The compact (non-Solidity) encoding is a separate impl block and is not
touched; its own strides already agree.

Add round-trips at one to four elements for a dynamic struct, a static
struct wider than one word, and a single-word static element, each
compared byte for byte against alloy, plus a check that arrays produced
by alloy decode without panicking.
Integers narrower than a word are padded out to 32 bytes. Negative
values pad with 0xFF, which is correct sign extension. The test that
picked the padding was `if value > 0`, so zero fell into the negative
branch and was encoded as 24 bytes of 0xFF followed by zeros.

Six cases were wrong, all of them the value zero, across u16/u32/u64 and
i16/i32/i64. Everything else already matched, including MIN and -1.

The codec reads its own output back correctly, so nothing inside the
repository noticed. Consumers outside it do: a strict decoder rejects the
word, and a log filter looking for a zero topic never matches. Events and
return values are affected, not just standalone integers, because the
same padding runs for fields inside structs and tuples.

Use `>= 0` instead. For the unsigned instantiations the test is then
always true and the padding is always zero; for the signed ones it is
exactly the sign test. The unused-comparison warning that follows on the
unsigned side is allowed with a note saying why.

Also drop the first of two identical writes of the value. Only the
padding either side of it changed in between, so the second write always
reproduced the first.

Add round-trips for every affected width at 0, 1, -1, MIN and MAX
compared against alloy, and one for a zero field inside a struct, since
that is the path events actually take.
…eckpoint

Each delegation is stored as a queue of (amount, epoch) entries, where
the epoch means "this balance applies from here". The reward claim used
that same field as its own bookmark for how far it had already paid, and
moved it forward on every claim - including when it paid nothing.

A validator is its own delegator, and its self-stake is a single entry,
so every claim moved it. Historical self-stake is answered by binary
searching that field, so after a claim the search found nothing at or
below an earlier epoch and reported zero stake. The validator then fell
out of the committee that a past epoch is recomputed to, and the views
the node uses to rebuild past committees stopped agreeing with what was
actually committed.

Give the pair its own `claimed_through_epoch` and leave queue entries
alone once written. `delegate_gap`, the index of the first unpaid entry,
becomes derivable from the cursor by the same binary search that was
already there, so it goes away - the struct keeps the same field count
but each field now means one thing.

Two details that are easy to get wrong:

The per-claim epoch window has to start at max(cursor, first entry), not
at the bare cursor. A delegator who has never claimed has a zero cursor,
and a window measured from zero would sit entirely before their first
delegation and never reach their rewards.

Equivocation seizure clears both queues, so it has to clear the cursor
too, in the same place.

`reward_claims_are_bounded_to_one_thousand_epochs` asserted the corrupted
value as the expected one, so it was green while the bug was live. It now
checks that the cursor advanced and that the entry's epoch did not.

A new test covers the consequence rather than the field: after a claim,
past-epoch self-stake is unchanged and the validator is still in the
selection view for that epoch.

The reference vector for encoding consensus keys now uses three elements
instead of one; with a single element every head slot sits at offset zero
and a wrong stride between slots cannot show up.
…the epoch

The epoch is never stored - it is derived from the block height as
(height - activation) / interval. A zero activation block is meant to be
the "scheduled, not armed yet" state: the governance guard leaves the
setters open on it, the node reads it the same way, and the README says
so. The derivation did not agree. With a zero activation it fell through
to height / interval, so an unarmed chain counted epochs from genesis.

Nothing is visibly wrong until an activation block is actually scheduled.
At that moment the current epoch drops back to zero, and the epoch is
what delegation records are keyed by.

With an interval of 200: the chain runs unarmed to block 4000, which
reads as epoch 20, and a delegation there is written as an entry
effective from epoch 22. Governance then sets the activation block to
6000 - allowed, it is a multiple of the interval and not in the past. On
block 4001 the current epoch is 0, so the next delegation targets epoch
2. The amount is added to the validator's total for epoch 2 and for every
later snapshot including 22, but the merge branch sees that the last
queue entry (22) is not below the target (2) and folds the amount into
that entry instead of appending a new one. For epochs 2 through 21 the
validator's total then holds an amount that no entry attributes to
anyone. The denominator is inflated for twenty epochs and every delegator
is paid short.

Clamp the derivation instead: a zero activation, or a height below the
activation, both give epoch 0. The epoch can then only move forward.
Both setters that could change the inputs are gated on the chain not
being armed yet, and the activation setter separately refuses a value
below the current height, so there is no reachable order of governance
calls that lowers it.

The tests missed this from both sides. The unit test for epoch_at_block
only ever passed a non-zero activation, and the test that pins
arming-from-zero as intended behaviour never moves the block, so it could
not see the epoch fall back. Both are extended, and a new test walks a
chain across a scheduled activation asserting the epoch never decreases.

reward_claims_are_bounded_to_one_thousand_epochs built its chain at block
zero, which is now the unarmed sentinel and pins every epoch at 0, so it
starts one interval in instead.
…e one

Settlement priced each epoch by reading blendStipendPerEpoch out of the
live config at the moment it paid. Three lines below that read, the
committee weights are taken from a frozen snapshot, and for the same
reason: the epoch is being paid after it ended, so anything read live
describes a different epoch than the one being settled.

A rate change lands in one write and takes effect immediately. Set it
part way through an epoch and that whole epoch - already worked, already
counted - is paid at the new number. Set it to zero and the epoch pays
nothing at all: settle_one emits its skip event and returns Ok, so the
cursor moves past it, and the guard on the cursor then refuses to settle
it ever again. The window is normally one epoch, because closing happens
on the first recorded block of the next one, but the catch-up path walks
a range and prices every epoch in it at today's value.

Snapshot the rate where the epoch is closed and read it back from there.
It is stored as rate + 1 in the same namespace as the block counters, so
zero keeps meaning "this epoch never closed" and stays distinguishable
from an epoch that genuinely closed at a rate of zero. Settling an epoch
with no snapshot reverts rather than returning early: a revert leaves the
cursor where it is and the epoch can be settled later, while an early
return would forfeit it, and an epoch whose price is unknown belongs on
the side that can still be recovered.

That revert cannot wedge the production path. Every epoch that recorded
blocks is closed by the next recorded block in a later epoch, and closing
is what writes the snapshot, so the two always arrive in that order. Only
a directly invoked settlement of an epoch that never closed can hit it,
and the node does not make that call.

The two test helpers that seed production counters write the snapshot the
same way close_epoch does. Pinning it inside the helpers rather than at
their thirty-odd call sites is what keeps the stand-in from drifting away
from the production path again; callers only have to configure the rate
before they seed.
@djadjka
djadjka force-pushed the feat/flu-989-port-solidity-delta branch from d124d88 to 61cbe4c Compare August 4, 2026 10:30
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

Criterion results (vs baseline)


running 121 tests


Heads-up: runner perf is noisy; treat deltas as a smoke check.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant