From 3cca4ff8557db365b163003353183591dcdf9c30 Mon Sep 17 00:00:00 2001 From: Aliaksei Dziadziuk Date: Mon, 3 Aug 2026 09:32:59 +0200 Subject: [PATCH 1/6] wip: update liveness logic --- .gitignore | 3 +- contracts/staking/README.md | 67 +- contracts/staking/src/config.rs | 414 +-- contracts/staking/src/consensus.rs | 337 +-- contracts/staking/src/consts.rs | 133 +- contracts/staking/src/events.rs | 123 +- contracts/staking/src/initializer.rs | 2 +- contracts/staking/src/lib.rs | 36 +- contracts/staking/src/liveness.rs | 592 ++++ contracts/staking/src/math.rs | 12 + contracts/staking/src/staking.rs | 323 ++- contracts/staking/src/storage.rs | 90 +- contracts/staking/src/tests.rs | 3797 +++++++++++++++++++------- contracts/staking/src/types.rs | 18 +- e2e/src/staking.rs | 71 +- 15 files changed, 4255 insertions(+), 1763 deletions(-) create mode 100644 contracts/staking/src/liveness.rs diff --git a/.gitignore b/.gitignore index 723da3d8b..e9d8ec106 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,5 @@ genesis-*.json node_modules/ evm-e2e/tests/ datadir/ -graphify-out/ \ No newline at end of file +graphify-out/ +.claude \ No newline at end of file diff --git a/contracts/staking/README.md b/contracts/staking/README.md index 238b79800..6fd1839eb 100644 --- a/contracts/staking/README.md +++ b/contracts/staking/README.md @@ -5,13 +5,16 @@ The core validator staking contract implemented as a normal rWasm contract and d ## Scope -- Implements validator lifecycle, delegation, rewards, committees, liveness jail, and equivocation slashing. -- Owns the chain configuration previously read from `ChainConfig`. -- Isolates initializer, chain configuration, consensus, and staking state in separate ERC-7201 namespaces: - `Fluent.storage.Initializer`, `Fluent.storage.ChainConfig`, - `Fluent.storage.Consensus`, and `Fluent.storage.StakingStorage`. +- Implements validator lifecycle, delegation, rewards, committees, equivocation slashing, and + block-production liveness. +- Owns the chain configuration previously read from `ChainConfig`, and the block-production + accounting previously held by a separate liveness contract. +- Isolates initializer, chain configuration, consensus, staking, and production-liveness state in + separate ERC-7201 namespaces: `Fluent.storage.Initializer`, `Fluent.storage.ChainConfig`, + `Fluent.storage.Consensus`, `Fluent.storage.StakingStorage`, and + `Fluent.storage.ProductionLiveness`. - Keeps `StakingPool` external and unchanged; this crate does not deploy or replace it. -- Calls configured BLS verifier, evidence decoder, liveness, and BLEND reserve contracts. +- Calls configured BLS verifier, evidence decoder, and BLEND reserve contracts. ## Lifecycle @@ -21,8 +24,9 @@ The core validator staking contract implemented as a normal rWasm contract and d genesis validator stake; it grants no contract authority. 3. Governance manages chain configuration, dependency rotation, and validator status. 4. Validator creation verifies and stores consensus keys atomically; delegators approve and deposit BLEND. -5. The system caller commits epoch committees and settles finalized epoch stipends. -6. Liveness and equivocation paths jail or permanently tombstone validators. +5. The system caller commits epoch committees and settles the stipend for epochs that have finished. +6. Verified equivocation permanently tombstones a validator and seizes its self-stake. Block-production liveness never + jails and never touches stake; it only excludes a validator from selection for a bounded number of epochs. Governance is fixed at compile time to the `GENESIS_GOVERNANCE` address. Changing it requires a coordinated code/genesis rebuild. The base genesis builder embeds staking but does not install governance code at the reserved address, so a @@ -46,7 +50,7 @@ key, proof of possession, and peer key in the validator-creation call; there is epoch. - A newly materialized snapshot copies only the latest state already effective at that epoch. Earlier-effective stake and commission changes are carried forward through any scheduled warm-up snapshots, never copied backward from them. -- Initialization, activation, jail readmission, and committee selection each require the validator owner's effective +- Initialization, activation, and committee selection each require the validator owner's effective self-stake to meet the configured minimum. A full owner exit moves an active validator to pending in the same transaction and removes its next-epoch selection visibility. - Delegation amounts must use `BALANCE_COMPACT_PRECISION`. @@ -58,18 +62,27 @@ key, proof of possession, and peer key in the validator-creation call; there is committee pruning cannot delay release. The same absolute committee deadlines expire equivocation evidence; `getValidatorSelfStakeLock` exposes the current lock state and exclusive unlock epoch. - Equivocation seizure consumes both active and pending self-principal. -- Claims, stipend catch-up, committee pruning, and jail scans are bounded per call. +- Claims, stipend catch-up, and committee pruning are bounded per call. - BLEND transfers accept ERC-20 tokens that return `true` or no data; explicit `false` reverts. +- The epoch stipend is flat pro-rata over the committee's frozen leader weights and consults no liveness verdict. The + only exclusions are a permanent equivocation tombstone and a zero frozen weight. - Reserve settlement credits rewards only after the exact assigned amount is disbursed. A successful zero or partial disbursement skips the epoch with zero credited rewards and advances the cursor; reverted calls and malformed return values revert settlement and remain retryable. -- Equivocation tombstones are permanent and prevent key reuse or jail release. +- Equivocation tombstones are permanent and prevent key reuse. - Compressed BLS public keys are stored as three fixed `bytes32` words. Validator creation rejects any verifier output that is not exactly 96 bytes, avoiding dynamic-bytes metadata and making malformed stored key lengths unrepresentable. -- Liveness jailing protects the fixed committed committee for the current epoch (or its selected - pre-commit fallback); sequential reports cannot ratchet down the quorum floor. -- Committee selection filters validators without active, correctly shaped consensus keys before - stake ranking and rejects empty committees without advancing the commit epoch. +- Committee selection ranks candidates by stake first and drops those without active, correctly + shaped consensus keys afterwards, and rejects empty committees without advancing the commit epoch. + The order matters: the off-chain deriver reads the same ranked view with inactive keys blanked and + discards the keyless entries itself, so filtering before the cut would promote a lower-staked + validator and make every honest submission fail. +- The committee-size cap is epoch-addressed. Changing it schedules the new value from the next epoch, + so an epoch that has already started keeps the cap it was selected under. The scalar getter reports + the latest scheduled value immediately and is not epoch-correct by design. +- Leader weights are frozen at commit time from the selection epoch, and are never recomputed on + read. An unfrozen weight would depend on the block height each node reads at, and the leader is + drawn from those weights. - Equivocation reporter rewards use a beneficiary-owned commit/reveal flow; the transaction sender that reveals evidence is never used as the reward recipient. - A validator's `owner` is its immutable administrative, validator-fee, self-stake, and slashing identity. @@ -127,9 +140,31 @@ bytes are unchanged. - `initializer.rs`: atomic one-shot initialization. - `config.rs`: chain configuration initialization, getters, setters, and dependencies. - `staking.rs`: epoch reads, validator administration, delegation, and rewards. -- `consensus.rs`: consensus keys, epoch committees, liveness, and equivocation handling. +- `consensus.rs`: consensus keys, epoch committees, and equivocation handling. +- `liveness.rs`: the block-production recorder and the epoch close. - `storage.rs`: separate ERC-7201 roots and epoch snapshots. +## Block-production liveness + +The system caller reports every block's producer through `recordProduction`. When the epoch rolls +over, the close runs three legs with three deliberately different failure policies: + +1. **Releases** — unconditional, and frozen by the `productionLivenessDisabled` kill switch. +2. **Verdicts** — fail-loud. An epoch whose recorded block count does not match the epoch interval is + tainted: it emits `PartialEpoch` and is not judged at all, because a partial record cannot + distinguish an idle validator from a missing report. +3. **Stipend** — tolerant. It runs in a fuel-capped self-call so a failing payment cannot roll back + the releases and verdicts of the same close; a failure emits `StipendLegSkipped` from the outer + frame. + +The consequence of failing liveness is a temporary, auto-reversing **exclusion** from committee +selection, never a stake penalty and never a jail — equivocation is the only path to `Jail`. An +exclusion is refused outright when no replacement can take the seat, and a refusal leaves no trace, +so a small network shrinks its committee rather than losing quorum. + +The stipend is paid only for an epoch that recorded blocks and has finished. A skipped epoch is +forfeited, not deferred. + ## Verification ```bash diff --git a/contracts/staking/src/config.rs b/contracts/staking/src/config.rs index 0f731f1d2..82daa5d5e 100644 --- a/contracts/staking/src/config.rs +++ b/contracts/staking/src/config.rs @@ -9,8 +9,8 @@ use crate::{ storage::chain_config_storage, types::{AddressCommand, BoolCommand, InitializeCommand, U256Command, U32Command, U64Command}, util::{ - decode, ensure_governance, ensure_mutable, ensure_non_payable, revert, revert_with, - write_abi, + decode, ensure_governance, ensure_mutable, ensure_non_payable, next_epoch, revert, + revert_with, write_abi, }, }; use alloc::string::String; @@ -43,15 +43,10 @@ pub(crate) fn apply_initial_config( config .active_validators_length_accessor() .set_checked(sdk, command.active_validators_length as u64)?; + schedule_cap_checkpoint(sdk, 0, command.active_validators_length)?; config .epoch_block_interval_accessor() .set_checked(sdk, command.epoch_block_interval as u64)?; - config - .felony_threshold_accessor() - .set_checked(sdk, command.felony_threshold)?; - config - .validator_jail_epoch_length_accessor() - .set_checked(sdk, command.validator_jail_epoch_length)?; config .undelegate_period_accessor() .set_checked(sdk, command.undelegate_period as u64)?; @@ -73,6 +68,16 @@ pub(crate) fn apply_initial_config( config .blend_reserve_accessor() .set_checked(sdk, command.blend_reserve)?; + config + .min_verdict_due_blocks_accessor() + .set_checked(sdk, DEFAULT_MIN_VERDICT_DUE_BLOCKS)?; + config + .exclusion_backoff_cap_accessor() + .set_checked(sdk, DEFAULT_EXCLUSION_BACKOFF_CAP)?; + // The tier ships off, and an unwritten slot would ship it on. + config + .production_liveness_disabled_accessor() + .set_checked(sdk, true)?; if !command.bls_verifier.is_zero() { config .bls_verifier_accessor() @@ -87,6 +92,7 @@ pub(crate) fn apply_initial_config( events::ActiveValidatorsLengthChanged { prev_value: DEFAULT_ACTIVE_VALIDATORS_LENGTH as u32, new_value: command.active_validators_length, + effective_epoch: 0, } .emit(sdk)?; events::EpochBlockIntervalChanged { @@ -94,16 +100,6 @@ pub(crate) fn apply_initial_config( new_value: command.epoch_block_interval, } .emit(sdk)?; - events::FelonyThresholdChanged { - prev_value: DEFAULT_FELONY_THRESHOLD, - new_value: command.felony_threshold, - } - .emit(sdk)?; - events::ValidatorJailEpochLengthChanged { - prev_value: DEFAULT_VALIDATOR_JAIL_EPOCH_LENGTH, - new_value: command.validator_jail_epoch_length, - } - .emit(sdk)?; events::UndelegatePeriodChanged { prev_value: DEFAULT_UNDELEGATE_PERIOD as u32, new_value: command.undelegate_period, @@ -124,6 +120,21 @@ pub(crate) fn apply_initial_config( new_value: command.dpos_activation_block, } .emit(sdk)?; + events::MinVerdictDueBlocksChanged { + prev_value: 0, + new_value: DEFAULT_MIN_VERDICT_DUE_BLOCKS, + } + .emit(sdk)?; + events::ExclusionBackoffCapChanged { + prev_value: 0, + new_value: DEFAULT_EXCLUSION_BACKOFF_CAP, + } + .emit(sdk)?; + events::ProductionLivenessDisabledChanged { + prev_value: false, + new_value: true, + } + .emit(sdk)?; if !command.bls_verifier.is_zero() { events::BlsVerifierChanged { prev_value: Address::ZERO, @@ -161,8 +172,6 @@ fn validate_initialization( if command.active_validators_length == 0 || command.active_validators_length as u64 > MAX_ACTIVE_VALIDATORS_LENGTH || command.epoch_block_interval == 0 - || command.felony_threshold == 0 - || command.validator_jail_epoch_length == 0 || command.undelegate_period == 0 || command.min_validator_stake_amount.is_zero() || command.min_staking_amount.is_zero() @@ -196,14 +205,6 @@ fn validate_initialization( Ok(()) } -/// Public handler `0x2c1d88e8` (`DEFAULT_PARTICIPATION_FLOOR_BPS`). -/// -/// Returns the protocol default participation floor BPS constant. -pub fn default_participation_floor_bps(sdk: &mut SDK) -> Result<(), ExitCode> { - ensure_non_payable(sdk)?; - write_abi(sdk, &DEFAULT_PARTICIPATION_FLOOR_BPS) -} - /// Public handler `0x6cc69027` (`DEFAULT_SLASH_REPORTER_BPS`). /// /// Returns the protocol default slash reporter BPS constant. @@ -228,14 +229,6 @@ pub fn max_blend_stipend_per_epoch(sdk: &mut SDK) -> Result<(), write_abi(sdk, &MAX_BLEND_STIPEND_PER_EPOCH) } -/// Public handler `0x9dbdf12b` (`MAX_PARTICIPATION_FLOOR_BPS`). -/// -/// Returns the protocol max participation floor BPS limit. -pub fn max_participation_floor_bps(sdk: &mut SDK) -> Result<(), ExitCode> { - ensure_non_payable(sdk)?; - write_abi(sdk, &MAX_PARTICIPATION_FLOOR_BPS) -} - /// Public handler `0x0a3a6183` (`MAX_SLASH_REPORTER_BPS`). /// /// Returns the protocol max slash reporter BPS limit. @@ -244,6 +237,30 @@ pub fn max_slash_reporter_bps(sdk: &mut SDK) -> Result<(), ExitC write_abi(sdk, &MAX_SLASH_REPORTER_REWARD_BPS) } +/// Public handler `0x6fd3afb7` (`DEFAULT_MIN_VERDICT_DUE_BLOCKS`). +/// +/// Returns the protocol default verdict due-block floor. +pub fn default_min_verdict_due_blocks(sdk: &mut SDK) -> Result<(), ExitCode> { + ensure_non_payable(sdk)?; + write_abi(sdk, &DEFAULT_MIN_VERDICT_DUE_BLOCKS) +} + +/// Public handler `0xd4c30c1a` (`DEFAULT_EXCLUSION_BACKOFF_CAP`). +/// +/// Returns the protocol default exclusion backoff cap. +pub fn default_exclusion_backoff_cap(sdk: &mut SDK) -> Result<(), ExitCode> { + ensure_non_payable(sdk)?; + write_abi(sdk, &DEFAULT_EXCLUSION_BACKOFF_CAP) +} + +/// Public handler `0x9b9a11ba` (`MAX_MIN_VERDICT_DUE_BLOCKS`). +/// +/// Returns the protocol ceiling on the verdict due-block floor. +pub fn max_min_verdict_due_blocks(sdk: &mut SDK) -> Result<(), ExitCode> { + ensure_non_payable(sdk)?; + write_abi(sdk, &MAX_MIN_VERDICT_DUE_BLOCKS) +} + /// Public handler `0x9f9106d1` (`getStakingToken`). /// /// Returns the configured staking token. @@ -270,6 +287,67 @@ pub fn get_active_validators_length(sdk: &mut SDK) -> Result<(), ) } +/// Committee size cap in force at `epoch`. +/// +/// Backward scan over the checkpoint history. An empty history means the +/// contract is not initialized yet, in which case the scalar is the only +/// answer available. +pub(crate) fn active_validators_length_at( + sdk: &SDK, + epoch: u64, +) -> Result { + let config = chain_config_storage(); + let checkpoints = config.cap_checkpoints_accessor(); + let mut index = checkpoints.len_checked(sdk)?; + while index > 0 { + index -= 1; + let checkpoint = checkpoints.at(index); + if checkpoint.from_epoch_accessor().get_checked(sdk)? <= epoch { + return Ok(checkpoint.value_accessor().get_checked(sdk)? as u64); + } + } + config + .active_validators_length_accessor() + .get_checked(sdk) +} + +/// Records `value` as the cap from `from_epoch` onward. +/// +/// A repeat set inside the same epoch overwrites the pending tail instead of +/// appending, so a scheduled-but-not-yet-effective cap can still be corrected +/// without leaving an unreachable checkpoint behind. +fn schedule_cap_checkpoint( + sdk: &mut SDK, + from_epoch: u64, + value: u32, +) -> Result<(), ExitCode> { + let checkpoints = chain_config_storage().cap_checkpoints_accessor(); + let len = checkpoints.len_checked(sdk)?; + if len > 0 { + let tail = checkpoints.at(len - 1); + if tail.from_epoch_accessor().get_checked(sdk)? == from_epoch { + return tail.value_accessor().set_checked(sdk, value); + } + } + // `grow_checked` does not zero the new element, so both fields are written. + let entry = checkpoints.grow_checked(sdk)?; + entry.from_epoch_accessor().set_checked(sdk, from_epoch)?; + entry.value_accessor().set_checked(sdk, value) +} + +/// Public handler `0xd9b083ba` (`getActiveValidatorsLengthAt`). +/// +/// Returns the committee size cap that was in force at `epoch`. +pub fn get_active_validators_length_at( + sdk: &mut SDK, + input: &[u8], +) -> Result<(), ExitCode> { + ensure_non_payable(sdk)?; + let epoch = decode::(input)?.value; + let value = active_validators_length_at(sdk, epoch)?; + write_abi(sdk, &value) +} + /// Public handler `0x346c90a8` (`getEpochBlockInterval`). /// /// Returns the configured epoch block interval. @@ -367,73 +445,6 @@ fn require_undelegate_window( Ok(()) } -/// Public handler `0xbe199738` (`getFelonyThreshold`). -/// -/// Returns the configured felony threshold. -pub fn get_felony_threshold(sdk: &mut SDK) -> Result<(), ExitCode> { - ensure_non_payable(sdk)?; - write_abi( - sdk, - &chain_config_storage() - .felony_threshold_accessor() - .get_checked(sdk)?, - ) -} - -/// Public handler `0xfcd6cb3e` (`setFelonyThreshold`). -/// -/// Updates the configured felony threshold. -pub fn set_felony_threshold(sdk: &mut SDK, input: &[u8]) -> Result<(), ExitCode> { - ensure_governance_mutation(sdk)?; - let value = decode::(input)?.value; - if value == 0 { - return zero_value(sdk, "felonyThreshold"); - } - let field = chain_config_storage().felony_threshold_accessor(); - let previous = field.get_checked(sdk)?; - field.set_checked(sdk, value)?; - events::FelonyThresholdChanged { - prev_value: previous, - new_value: value, - } - .emit(sdk) -} - -/// Public handler `0x6cbe6cd8` (`getValidatorJailEpochLength`). -/// -/// Returns the configured validator jail epoch length. -pub fn get_validator_jail_epoch_length(sdk: &mut SDK) -> Result<(), ExitCode> { - ensure_non_payable(sdk)?; - write_abi( - sdk, - &chain_config_storage() - .validator_jail_epoch_length_accessor() - .get_checked(sdk)?, - ) -} - -/// Public handler `0xc8652bd5` (`setValidatorJailEpochLength`). -/// -/// Updates the configured validator jail epoch length. -pub fn set_validator_jail_epoch_length( - sdk: &mut SDK, - input: &[u8], -) -> Result<(), ExitCode> { - ensure_governance_mutation(sdk)?; - let value = decode::(input)?.value; - if value == 0 { - return zero_value(sdk, "validatorJailEpochLength"); - } - let field = chain_config_storage().validator_jail_epoch_length_accessor(); - let previous = field.get_checked(sdk)?; - field.set_checked(sdk, value)?; - events::ValidatorJailEpochLengthChanged { - prev_value: previous, - new_value: value, - } - .emit(sdk) -} - /// Public handler `0xce534df5` (`getSlashReporterRewardBps`). /// /// Returns the configured slash reporter reward BPS. @@ -513,85 +524,6 @@ pub fn set_slash_fund_address(sdk: &mut SDK, input: &[u8]) -> Re .emit(sdk) } -/// Public handler `0x4baffdc4` (`getParticipationFloorBps`). -/// -/// Returns the configured participation floor BPS. -pub fn get_participation_floor_bps(sdk: &mut SDK) -> Result<(), ExitCode> { - ensure_non_payable(sdk)?; - let stored = chain_config_storage() - .participation_floor_bps_accessor() - .get_checked(sdk)?; - write_abi( - sdk, - &(if stored == 0 { - DEFAULT_PARTICIPATION_FLOOR_BPS - } else { - stored - }), - ) -} - -/// Public handler `0xd0a01007` (`setParticipationFloorBps`). -/// -/// Updates the configured participation floor BPS. -pub fn set_participation_floor_bps( - sdk: &mut SDK, - input: &[u8], -) -> Result<(), ExitCode> { - ensure_governance_mutation(sdk)?; - let value = decode::(input)?.value; - if value == 0 { - return zero_value(sdk, "participationFloorBps"); - } - if value > MAX_PARTICIPATION_FLOOR_BPS { - return revert_with( - sdk, - ERR_PARTICIPATION_FLOOR_BPS_TOO_HIGH, - &(value, MAX_PARTICIPATION_FLOOR_BPS), - ); - } - let field = chain_config_storage().participation_floor_bps_accessor(); - let previous = field.get_checked(sdk)?; - field.set_checked(sdk, value)?; - events::ParticipationFloorBpsChanged { - prev_value: previous, - new_value: value, - } - .emit(sdk) -} - -/// Public handler `0x485fd959` (`getParticipationJailDisabled`). -/// -/// Returns the configured participation jail disabled. -pub fn get_participation_jail_disabled(sdk: &mut SDK) -> Result<(), ExitCode> { - ensure_non_payable(sdk)?; - write_abi( - sdk, - &chain_config_storage() - .participation_jail_disabled_accessor() - .get_checked(sdk)?, - ) -} - -/// Public handler `0x8664f2e7` (`setParticipationJailDisabled`). -/// -/// Updates the configured participation jail disabled. -pub fn set_participation_jail_disabled( - sdk: &mut SDK, - input: &[u8], -) -> Result<(), ExitCode> { - ensure_governance_mutation(sdk)?; - let value = decode::(input)?.value; - let field = chain_config_storage().participation_jail_disabled_accessor(); - let previous = field.get_checked(sdk)?; - field.set_checked(sdk, value)?; - events::ParticipationJailDisabledChanged { - prev_value: previous, - new_value: value, - } - .emit(sdk) -} - /// Public handler `0xc8f45d87` (`getBlendStipendPerEpoch`). /// /// Returns the configured blend stipend per epoch. @@ -652,10 +584,16 @@ pub fn set_active_validators_length( } let field = chain_config_storage().active_validators_length_accessor(); let previous = field.get_checked(sdk)?; + // The scalar reports the latest SCHEDULED value immediately; the checkpoint + // governs epoch-correct reads. Splitting the two is what keeps an epoch that + // has already started immutable. field.set_checked(sdk, value as u64)?; + let effective_epoch = next_epoch(sdk)?; + schedule_cap_checkpoint(sdk, effective_epoch, value)?; events::ActiveValidatorsLengthChanged { prev_value: previous as u32, new_value: value, + effective_epoch, } .emit(sdk) } @@ -789,6 +727,118 @@ pub fn set_min_staking_amount(sdk: &mut SDK, input: &[u8]) -> Re .emit(sdk) } +/// Public handler `0xee3ad0e7` (`getMinVerdictDueBlocks`). +/// +/// Returns the configured verdict due-block floor. +pub fn get_min_verdict_due_blocks(sdk: &mut SDK) -> Result<(), ExitCode> { + ensure_non_payable(sdk)?; + write_abi( + sdk, + &chain_config_storage() + .min_verdict_due_blocks_accessor() + .get_checked(sdk)?, + ) +} + +/// Public handler `0x4fae9dea` (`setMinVerdictDueBlocks`). +/// +/// Updates the due-block floor below which a committee member holds no verdict. +/// +/// Zero would judge a member that was never due a single slot; the ceiling +/// keeps the floor from silently disabling the tier for every member at once. +pub fn set_min_verdict_due_blocks( + sdk: &mut SDK, + input: &[u8], +) -> Result<(), ExitCode> { + ensure_governance_mutation(sdk)?; + let value = decode::(input)?.value; + if value == 0 { + return zero_value(sdk, "minVerdictDueBlocks"); + } + if value > MAX_MIN_VERDICT_DUE_BLOCKS { + return revert_with( + sdk, + ERR_MIN_VERDICT_DUE_BLOCKS_TOO_HIGH, + &(value, MAX_MIN_VERDICT_DUE_BLOCKS), + ); + } + let field = chain_config_storage().min_verdict_due_blocks_accessor(); + let previous = field.get_checked(sdk)?; + field.set_checked(sdk, value)?; + events::MinVerdictDueBlocksChanged { + prev_value: previous, + new_value: value, + } + .emit(sdk) +} + +/// Public handler `0x6bed0322` (`getExclusionBackoffCap`). +/// +/// Returns the configured exclusion backoff cap. +pub fn get_exclusion_backoff_cap(sdk: &mut SDK) -> Result<(), ExitCode> { + ensure_non_payable(sdk)?; + write_abi( + sdk, + &chain_config_storage() + .exclusion_backoff_cap_accessor() + .get_checked(sdk)?, + ) +} + +/// Public handler `0x3b543e1c` (`setExclusionBackoffCap`). +/// +/// Updates the ceiling on the linear exclusion ladder, in selection epochs. +pub fn set_exclusion_backoff_cap( + sdk: &mut SDK, + input: &[u8], +) -> Result<(), ExitCode> { + ensure_governance_mutation(sdk)?; + let value = decode::(input)?.value; + if value == 0 { + return zero_value(sdk, "exclusionBackoffCap"); + } + let field = chain_config_storage().exclusion_backoff_cap_accessor(); + let previous = field.get_checked(sdk)?; + field.set_checked(sdk, value)?; + events::ExclusionBackoffCapChanged { + prev_value: previous, + new_value: value, + } + .emit(sdk) +} + +/// Public handler `0x9a4c46bb` (`getProductionLivenessDisabled`). +/// +/// Returns whether the production-liveness tier is switched off. +pub fn get_production_liveness_disabled(sdk: &mut SDK) -> Result<(), ExitCode> { + ensure_non_payable(sdk)?; + write_abi( + sdk, + &chain_config_storage() + .production_liveness_disabled_accessor() + .get_checked(sdk)?, + ) +} + +/// Public handler `0x8fc07556` (`setProductionLivenessDisabled`). +/// +/// Switches the production-liveness tier on or off. +pub fn set_production_liveness_disabled( + sdk: &mut SDK, + input: &[u8], +) -> Result<(), ExitCode> { + ensure_governance_mutation(sdk)?; + let value = decode::(input)?.value; + let field = chain_config_storage().production_liveness_disabled_accessor(); + let previous = field.get_checked(sdk)?; + field.set_checked(sdk, value)?; + events::ProductionLivenessDisabledChanged { + prev_value: previous, + new_value: value, + } + .emit(sdk) +} + /// Public handler `0xc6b904ad` (`getBlsVerifier`). /// /// Returns the configured BLS verifier. diff --git a/contracts/staking/src/consensus.rs b/contracts/staking/src/consensus.rs index 127c45790..e0c58006d 100644 --- a/contracts/staking/src/consensus.rs +++ b/contracts/staking/src/consensus.rs @@ -4,9 +4,8 @@ use crate::{ consts::*, events, math, staking::{ - remove_active, selected_validators, selected_validators_at, selection_candidates_at, - set_selection_visible, top_k_by_stake_at, touch_snapshot_at_or_before, - validator_has_minimum_self_stake_at, validator_total_at, + remove_active, selected_validators, selected_validators_at, set_selection_visible, + validator_total_at, }, storage::{chain_config_storage, consensus_storage, staking_storage}, types::{ @@ -15,7 +14,7 @@ use crate::{ }, util::{ current_epoch, decode, decode_args, ensure_initialized, ensure_mutable, ensure_non_payable, - next_epoch, revert, revert_with, safe_transfer, write_abi, + revert, revert_with, safe_transfer, write_abi, }, }; use alloc::vec::Vec; @@ -387,18 +386,24 @@ fn has_active_consensus_keys_at( Ok(!keys.peer_pubkey.is_zero() && keys.activation_epoch <= epoch) } +/// Committee for `epoch`: the selection view, retained to members whose +/// consensus keys are active by `epoch`. +/// +/// The key filter runs *after* the stake cut, never before. The off-chain +/// deriver builds its array from `getValidatorsWithKeysAt`, which is this same +/// selection view with inactive keys zeroed, and drops the keyless entries +/// itself. Filtering before the cut would promote a lower-staked keyed +/// validator into the committee and disagree with the array the deriver +/// submits, so `commitEpochCommittee` would reject every honest submission. fn selected_committee_at(sdk: &SDK, epoch: u64) -> Result, ExitCode> { - let candidates = selection_candidates_at(sdk, epoch)?; - let mut eligible = Vec::with_capacity(candidates.len()); - for validator in candidates { + let selected = selected_validators_at(sdk, epoch)?; + let mut eligible = Vec::with_capacity(selected.len()); + for validator in selected { if has_active_consensus_keys_at(sdk, validator, epoch)? { eligible.push(validator); } } - let cap = chain_config_storage() - .active_validators_length_accessor() - .get_checked(sdk)? as usize; - top_k_by_stake_at(sdk, eligible, epoch, cap) + Ok(eligible) } fn prune_committees(sdk: &mut SDK, current: u64) -> Result<(), ExitCode> { @@ -420,6 +425,12 @@ fn prune_committees(sdk: &mut SDK, current: u64) -> Result<(), E .epoch_committees_accessor() .entry(cursor) .clear_checked(sdk)?; + // Both arrays are positional with each other, so pruning one without the + // other manufactures the length mismatch the reader reverts on. + storage + .leader_stakes_accessor() + .entry(cursor) + .clear_checked(sdk)?; liability_end.set_checked(sdk, 0)?; cursor = cursor.checked_add(1).ok_or(ExitCode::IntegerOverflow)?; deleted += 1; @@ -515,8 +526,15 @@ pub fn commit_epoch_committee(sdk: &mut SDK, input: &[u8]) -> Re .ok_or(ExitCode::IntegerOverflow)?; let changed = committee_changed(sdk, target, &submitted)?; let stored = storage.epoch_committees_accessor().entry(target); + let stored_stakes = storage.leader_stakes_accessor().entry(target); for validator in &submitted { stored.push_checked(sdk, *validator)?; + // Weights are stamped from the SELECTION epoch, the same vintage that + // ranked membership, so leader weight and committee order cannot + // disagree about a member. + let weight = math::compact_balance(validator_total_at(sdk, *validator, selection_epoch)?) + .ok_or(ExitCode::IntegerOverflow)?; + stored_stakes.push_checked(sdk, weight)?; } storage .committee_liability_end_epochs_accessor() @@ -634,287 +652,31 @@ pub fn get_epoch_committee_with_stakes( ensure_initialized(sdk)?; let epoch = decode::(input)?.value; let validators = read_committee(sdk, epoch)?; + let frozen = consensus_storage().leader_stakes_accessor().entry(epoch); + let frozen_len = frozen.len_checked(sdk)?; + if frozen_len != validators.len() as u64 { + // Deliberately no fallback to a live walk: that would restore the + // height-dependent read this freeze exists to remove. + return revert_with( + sdk, + ERR_LEADER_STAKES_LENGTH_MISMATCH, + &( + epoch, + U256::from(validators.len()), + U256::from(frozen_len), + ), + ); + } let mut keys = Vec::with_capacity(validators.len()); let mut stakes = Vec::with_capacity(validators.len()); - for validator in &validators { + for (index, validator) in validators.iter().enumerate() { keys.push(read_consensus_keys(sdk, *validator)?); - stakes.push(validator_total_at(sdk, *validator, epoch)?); + stakes.push(math::expand_balance( + frozen.at(index as u64).get_checked(sdk)?, + )); } write_returns(sdk, &(validators, keys, stakes)) } -// Liveness slashing, jail transitions, and bounded readmission. - -fn ensure_liveness(sdk: &mut SDK) -> Result<(), ExitCode> { - ensure_non_payable(sdk)?; - ensure_mutable(sdk)?; - ensure_initialized(sdk)?; - let caller = sdk.context().contract_caller(); - let expected = chain_config_storage() - .liveness_slashing_accessor() - .get_checked(sdk)?; - if expected.is_zero() || caller != expected { - return revert(sdk, ERR_ONLY_LIVENESS_SLASHING); - } - Ok(()) -} - -fn remove_jailed(sdk: &mut SDK, validator: Address) -> Result<(), ExitCode> { - let jailed = consensus_storage().jailed_validators_accessor(); - let len = jailed.len_checked(sdk)?; - for index in 0..len { - if jailed.at(index).get_checked(sdk)? != validator { - continue; - } - if index + 1 != len { - let last = jailed.at(len - 1).get_checked(sdk)?; - jailed.at(index).set_checked(sdk, last)?; - } - jailed.pop_checked(sdk)?; - break; - } - Ok(()) -} - -fn readmit(sdk: &mut SDK, validator: Address) -> Result<(), ExitCode> { - if !validator_has_minimum_self_stake_at(sdk, validator, next_epoch(sdk)?)? { - return revert(sdk, ERR_OWNER_SELF_STAKE_BELOW_MINIMUM); - } - let storage = staking_storage(); - storage - .validators_accessor() - .entry(validator) - .status_accessor() - .set_checked(sdk, STATUS_ACTIVE)?; - storage - .active_validators_accessor() - .push_checked(sdk, validator)?; - remove_jailed(sdk, validator)?; - let epoch = current_epoch(sdk)?; - set_selection_visible(sdk, validator, true, epoch)?; - events::ValidatorReleased { validator, epoch }.emit(sdk) -} - -/// Public handler `0x73a3dda6` (`releaseValidatorFromJail`). -/// -/// Releases an eligible validator from jail at its owner's request. -pub fn release_validator_from_jail( - sdk: &mut SDK, - input: &[u8], -) -> Result<(), ExitCode> { - ensure_non_payable(sdk)?; - ensure_mutable(sdk)?; - ensure_initialized(sdk)?; - let validator = decode::(input)?.value; - let storage = staking_storage(); - if consensus_storage() - .tombstoned_accessor() - .entry(validator) - .get_checked(sdk)? - { - return revert_with(sdk, ERR_ALREADY_SLASHED_FOR_EQUIVOCATION, &validator); - } - let record = storage.validators_accessor().entry(validator); - if record.status_accessor().get_checked(sdk)? != STATUS_JAIL { - return revert_with(sdk, ERR_VALIDATOR_NOT_IN_JAIL, &validator); - } - let owner = record.owner_accessor().get_checked(sdk)?; - if sdk.context().contract_caller() != owner { - return revert_with(sdk, ERR_ONLY_VALIDATOR_OWNER, &owner); - } - if current_epoch(sdk)? < record.jailed_before_accessor().get_checked(sdk)? { - return revert_with(sdk, ERR_STILL_IN_JAIL, &validator); - } - readmit(sdk, validator) -} - -/// Public handler `0x67d80300` (`readmitExpiredJails`). -/// -/// Readmits validators whose jail periods expired by the requested epoch. -pub fn readmit_expired_jails(sdk: &mut SDK, input: &[u8]) -> Result<(), ExitCode> { - ensure_liveness(sdk)?; - let _: U64Command = decode(input)?; - let epoch = current_epoch(sdk)?; - let storage = staking_storage(); - let consensus = consensus_storage(); - let jailed = consensus.jailed_validators_accessor(); - let len = jailed.len_checked(sdk)?; - if len == 0 { - consensus - .jailed_scan_cursor_accessor() - .set_checked(sdk, 0)?; - return Ok(()); - } - let mut index = consensus.jailed_scan_cursor_accessor().get_checked(sdk)? % len; - let mut examined = 0; - // Persist the cursor so each call does bounded work without starving entries. - let budget = core::cmp::min(len, MAX_ACTIVE_VALIDATORS_LENGTH); - while examined < budget { - let current_len = jailed.len_checked(sdk)?; - if current_len == 0 { - index = 0; - break; - } - if index >= current_len { - index = 0; - } - let validator = jailed.at(index).get_checked(sdk)?; - let record = storage.validators_accessor().entry(validator); - if consensus - .tombstoned_accessor() - .entry(validator) - .get_checked(sdk)? - { - remove_jailed(sdk, validator)?; - } else if record.status_accessor().get_checked(sdk)? == STATUS_JAIL - && epoch >= record.jailed_before_accessor().get_checked(sdk)? - { - if validator_has_minimum_self_stake_at(sdk, validator, next_epoch(sdk)?)? { - readmit(sdk, validator)?; - } else { - index = (index + 1) % current_len; - } - } else { - index = (index + 1) % current_len; - } - examined += 1; - } - let remaining = jailed.len_checked(sdk)?; - consensus - .jailed_scan_cursor_accessor() - .set_checked(sdk, if remaining == 0 { 0 } else { index % remaining }) -} - -fn quorum(n: u64) -> u64 { - if n == 0 { - return 0; - } - n - (n - 1) / 3 -} - -/// Returns the fixed committee size, its currently active members, and whether -/// `validator` belongs to it for the liveness incident at `epoch`. -/// -/// A committed committee is the canonical checkpoint. Before that checkpoint -/// exists (notably in genesis/unit-test flows), epoch-selected membership is -/// already historical: liveness removals only change visibility from E+1. -fn liveness_committee_state( - sdk: &SDK, - epoch: u64, - validator: Address, -) -> Result<(u64, u64, bool), ExitCode> { - let consensus = consensus_storage(); - let committed = consensus - .last_committed_epoch_p1_accessor() - .get_checked(sdk)? - > epoch; - let committee = if committed { - read_committee(sdk, epoch)? - } else { - selected_validators_at(sdk, epoch)? - }; - let storage = staking_storage(); - let mut active_members = 0u64; - let mut contains_validator = false; - for member in &committee { - if *member == validator { - contains_validator = true; - } - if storage - .validators_accessor() - .entry(*member) - .status_accessor() - .get_checked(sdk)? - == STATUS_ACTIVE - { - active_members += 1; - } - } - Ok((committee.len() as u64, active_members, contains_validator)) -} - -/// Public handler `0xc96be4cb` (`slash`). -/// -/// Applies liveness slashing to a validator on an authorized system call. -pub fn slash(sdk: &mut SDK, input: &[u8]) -> Result<(), ExitCode> { - ensure_liveness(sdk)?; - let validator = decode::(input)?.value; - let storage = staking_storage(); - let record = storage.validators_accessor().entry(validator); - let status = record.status_accessor().get_checked(sdk)?; - if status == STATUS_NOT_FOUND { - return revert_with(sdk, ERR_VALIDATOR_NOT_FOUND, &validator); - } - let epoch = current_epoch(sdk)?; - let snapshot = touch_snapshot_at_or_before(sdk, validator, epoch)?; - let slashes = snapshot - .slashes_count_accessor() - .get_checked(sdk)? - .checked_add(1) - .ok_or(ExitCode::IntegerOverflow)?; - snapshot - .slashes_count_accessor() - .set_checked(sdk, slashes)?; - - let threshold = chain_config_storage() - .felony_threshold_accessor() - .get_checked(sdk)?; - if slashes >= threshold { - if status == STATUS_JAIL { - let jail_until = epoch - .checked_add( - chain_config_storage() - .validator_jail_epoch_length_accessor() - .get_checked(sdk)? as u64, - ) - .ok_or(ExitCode::IntegerOverflow)?; - let current_deadline = record.jailed_before_accessor().get_checked(sdk)?; - record - .jailed_before_accessor() - .set_checked(sdk, core::cmp::max(current_deadline, jail_until))?; - events::ValidatorJailed { validator, epoch }.emit(sdk)?; - } else { - let (committee_len, active_members, is_committee_member) = - liveness_committee_state(sdk, epoch, validator)?; - let quorum_floor = quorum(committee_len); - if status == STATUS_ACTIVE - && is_committee_member - && (active_members == 0 || active_members - 1 < quorum_floor) - { - events::LivenessJailSkippedHaltGuard { - validator, - epoch, - active_set_size: U256::from(active_members), - quorum_floor: U256::from(quorum_floor), - } - .emit(sdk)?; - } else { - let jail_until = epoch - .checked_add( - chain_config_storage() - .validator_jail_epoch_length_accessor() - .get_checked(sdk)? as u64, - ) - .ok_or(ExitCode::IntegerOverflow)?; - record.status_accessor().set_checked(sdk, STATUS_JAIL)?; - record - .jailed_before_accessor() - .set_checked(sdk, jail_until)?; - remove_active(sdk, validator)?; - consensus_storage() - .jailed_validators_accessor() - .push_checked(sdk, validator)?; - set_selection_visible(sdk, validator, false, epoch)?; - events::ValidatorJailed { validator, epoch }.emit(sdk)?; - } - } - } - events::ValidatorSlashed { - validator, - slashes, - epoch, - } - .emit(sdk) -} // Equivocation proofs and permanent validator tombstoning. const BLS_SIG_DST: &[u8] = b"BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_POP_"; @@ -1290,8 +1052,7 @@ fn slash_equivocation( return revert(sdk, ERR_EQUIVOCATION_SIGNATURE_INVALID); } - // A verified conflict is terminal: the validator cannot re-register keys - // or return through the ordinary jail-release path. + // A verified conflict is terminal: the validator cannot re-register keys. consensus .tombstoned_accessor() .entry(validator) diff --git a/contracts/staking/src/consts.rs b/contracts/staking/src/consts.rs index 7da538412..8066381a5 100644 --- a/contracts/staking/src/consts.rs +++ b/contracts/staking/src/consts.rs @@ -3,7 +3,7 @@ use fluentbase_sdk::{ address, derive::{derive_keccak256_id, erc7201_slot}, - Address, U256, + Address, FUEL_DENOM_RATE, U256, }; pub const SIG_LEN_BYTES: usize = 4; @@ -16,10 +16,10 @@ pub const STATUS_JAIL: u8 = 3; // ABI selectors are derived from their canonical signatures. The pinned hex // values remain beside them to make ABI drift visible during review. -// 0x4b4b21a5 +// 0xd86555fe pub const SIG_INITIALIZE: u32 = derive_keccak256_id!( - "initialize(address,address[],uint256[],bytes[],bytes[],bytes32[],uint16,address,uint32,uint32,uint32,uint32,uint32,uint256,uint256,uint64,address,address,uint256,address,address)" + "initialize(address,address[],uint256[],bytes[],bytes[],bytes32[],uint16,address,uint32,uint32,uint32,uint256,uint256,uint64,address,address,uint256,address,address)" ); // 0x76671808 pub const SIG_CURRENT_EPOCH: u32 = derive_keccak256_id!("currentEpoch()"); @@ -53,6 +53,9 @@ pub const SIG_GET_STAKING_TOKEN: u32 = derive_keccak256_id!("getStakingToken()") // 0x32cc6f08 pub const SIG_GET_ACTIVE_VALIDATORS_LENGTH: u32 = derive_keccak256_id!("getActiveValidatorsLength()"); +// 0xd9b083ba +pub const SIG_GET_ACTIVE_VALIDATORS_LENGTH_AT: u32 = + derive_keccak256_id!("getActiveValidatorsLengthAt(uint64)"); // 0x346c90a8 pub const SIG_GET_EPOCH_BLOCK_INTERVAL: u32 = derive_keccak256_id!("getEpochBlockInterval()"); // 0xa2a50528 @@ -77,9 +80,6 @@ pub const SIG_REGISTER_VALIDATOR: u32 = pub const SIG_DELEGATE: u32 = derive_keccak256_id!("delegate(address,uint256)"); // 0x4d99dd16 pub const SIG_UNDELEGATE: u32 = derive_keccak256_id!("undelegate(address,uint256)"); -// 0x2c1d88e8 -pub const SIG_DEFAULT_PARTICIPATION_FLOOR_BPS: u32 = - derive_keccak256_id!("DEFAULT_PARTICIPATION_FLOOR_BPS()"); // 0x6cc69027 pub const SIG_DEFAULT_SLASH_REPORTER_BPS: u32 = derive_keccak256_id!("DEFAULT_SLASH_REPORTER_BPS()"); @@ -88,26 +88,22 @@ pub const SIG_MAX_ACTIVE_VALIDATORS: u32 = derive_keccak256_id!("MAX_ACTIVE_VALI // 0x2bc2fec4 pub const SIG_MAX_BLEND_STIPEND_PER_EPOCH: u32 = derive_keccak256_id!("MAX_BLEND_STIPEND_PER_EPOCH()"); -// 0x9dbdf12b -pub const SIG_MAX_PARTICIPATION_FLOOR_BPS: u32 = - derive_keccak256_id!("MAX_PARTICIPATION_FLOOR_BPS()"); // 0x0a3a6183 pub const SIG_MAX_SLASH_REPORTER_BPS: u32 = derive_keccak256_id!("MAX_SLASH_REPORTER_BPS()"); +// 0x6fd3afb7 +pub const SIG_DEFAULT_MIN_VERDICT_DUE_BLOCKS: u32 = + derive_keccak256_id!("DEFAULT_MIN_VERDICT_DUE_BLOCKS()"); +// 0xd4c30c1a +pub const SIG_DEFAULT_EXCLUSION_BACKOFF_CAP: u32 = + derive_keccak256_id!("DEFAULT_EXCLUSION_BACKOFF_CAP()"); +// 0x9b9a11ba +pub const SIG_MAX_MIN_VERDICT_DUE_BLOCKS: u32 = + derive_keccak256_id!("MAX_MIN_VERDICT_DUE_BLOCKS()"); // 0x23b872dd pub const SIG_ERC20_TRANSFER_FROM: u32 = derive_keccak256_id!("transferFrom(address,address,uint256)"); // 0xa9059cbb pub const SIG_ERC20_TRANSFER: u32 = derive_keccak256_id!("transfer(address,uint256)"); -// 0xbe199738 -pub const SIG_GET_FELONY_THRESHOLD: u32 = derive_keccak256_id!("getFelonyThreshold()"); -// 0xfcd6cb3e -pub const SIG_SET_FELONY_THRESHOLD: u32 = derive_keccak256_id!("setFelonyThreshold(uint32)"); -// 0x6cbe6cd8 -pub const SIG_GET_VALIDATOR_JAIL_EPOCH_LENGTH: u32 = - derive_keccak256_id!("getValidatorJailEpochLength()"); -// 0xc8652bd5 -pub const SIG_SET_VALIDATOR_JAIL_EPOCH_LENGTH: u32 = - derive_keccak256_id!("setValidatorJailEpochLength(uint32)"); // 0xce534df5 pub const SIG_GET_SLASH_REPORTER_REWARD_BPS: u32 = derive_keccak256_id!("getSlashReporterRewardBps()"); @@ -118,17 +114,6 @@ pub const SIG_SET_SLASH_REPORTER_REWARD_BPS: u32 = pub const SIG_GET_SLASH_FUND_ADDRESS: u32 = derive_keccak256_id!("getSlashFundAddress()"); // 0xa79e7263 pub const SIG_SET_SLASH_FUND_ADDRESS: u32 = derive_keccak256_id!("setSlashFundAddress(address)"); -// 0x4baffdc4 -pub const SIG_GET_PARTICIPATION_FLOOR_BPS: u32 = derive_keccak256_id!("getParticipationFloorBps()"); -// 0xd0a01007 -pub const SIG_SET_PARTICIPATION_FLOOR_BPS: u32 = - derive_keccak256_id!("setParticipationFloorBps(uint32)"); -// 0x485fd959 -pub const SIG_GET_PARTICIPATION_JAIL_DISABLED: u32 = - derive_keccak256_id!("getParticipationJailDisabled()"); -// 0x8664f2e7 -pub const SIG_SET_PARTICIPATION_JAIL_DISABLED: u32 = - derive_keccak256_id!("setParticipationJailDisabled(bool)"); // 0xc8f45d87 pub const SIG_GET_BLEND_STIPEND_PER_EPOCH: u32 = derive_keccak256_id!("getBlendStipendPerEpoch()"); // 0x2c91b879 @@ -165,6 +150,40 @@ pub const SIG_SET_LIVENESS_SLASHING: u32 = derive_keccak256_id!("setLivenessSlas pub const SIG_GET_BLEND_RESERVE: u32 = derive_keccak256_id!("getBlendReserve()"); // 0x7899ae8f pub const SIG_SET_BLEND_RESERVE: u32 = derive_keccak256_id!("setBlendReserve(address)"); +// 0xee3ad0e7 +pub const SIG_GET_MIN_VERDICT_DUE_BLOCKS: u32 = derive_keccak256_id!("getMinVerdictDueBlocks()"); +// 0x4fae9dea +pub const SIG_SET_MIN_VERDICT_DUE_BLOCKS: u32 = + derive_keccak256_id!("setMinVerdictDueBlocks(uint32)"); +// 0x6bed0322 +pub const SIG_GET_EXCLUSION_BACKOFF_CAP: u32 = derive_keccak256_id!("getExclusionBackoffCap()"); +// 0x3b543e1c +pub const SIG_SET_EXCLUSION_BACKOFF_CAP: u32 = + derive_keccak256_id!("setExclusionBackoffCap(uint32)"); +// 0x9a4c46bb +pub const SIG_GET_PRODUCTION_LIVENESS_DISABLED: u32 = + derive_keccak256_id!("getProductionLivenessDisabled()"); +// 0x8fc07556 +pub const SIG_SET_PRODUCTION_LIVENESS_DISABLED: u32 = + derive_keccak256_id!("setProductionLivenessDisabled(bool)"); +// 0x8e948ac1 +pub const SIG_GET_PRODUCTION_STATS: u32 = + derive_keccak256_id!("getProductionStats(address,uint64)"); +// 0xf06be669 +pub const SIG_BLOCKS_IN_EPOCH: u32 = derive_keccak256_id!("blocksInEpoch(uint64)"); +// 0x91c7d453 +pub const SIG_PRODUCED_AT: u32 = derive_keccak256_id!("producedAt(uint64,uint32)"); +// 0xaef690f9 +pub const SIG_PENDING_EXCLUSIONS: u32 = derive_keccak256_id!("pendingExclusions()"); +// 0x32066046 +pub const SIG_READMIT_AT_EPOCH: u32 = derive_keccak256_id!("readmitAtEpoch(address)"); +// 0x33de61d2 +pub const SIG_LAST_PROCESSED_BLOCK: u32 = derive_keccak256_id!("lastProcessedBlock()"); +// 0x8244a2c2 +pub const SIG_RECORD_PRODUCTION: u32 = derive_keccak256_id!("recordProduction(uint64,uint8)"); +// 0x92d321ab +pub const SIG_SETTLE_EPOCH_STIPEND_FROM: u32 = + derive_keccak256_id!("settleEpochStipendFrom(uint64)"); // 0x457179fd pub const SIG_GET_VALIDATOR_FEE: u32 = derive_keccak256_id!("getValidatorFee(address)"); // 0xc6fb9065 @@ -225,21 +244,10 @@ pub const SIG_GET_EPOCH_COMMITTEE_LENGTH: u32 = // 0xa4d160c1 pub const SIG_GET_EPOCH_COMMITTEE_WITH_STAKES: u32 = derive_keccak256_id!("getEpochCommitteeWithStakes(uint64)"); -// 0x73a3dda6 -pub const SIG_RELEASE_VALIDATOR_FROM_JAIL: u32 = - derive_keccak256_id!("releaseValidatorFromJail(address)"); -// 0x67d80300 -pub const SIG_READMIT_EXPIRED_JAILS: u32 = derive_keccak256_id!("readmitExpiredJails(uint64)"); -// 0xc96be4cb -pub const SIG_SLASH: u32 = derive_keccak256_id!("slash(address)"); // 0xa5d2dd22 pub const SIG_BLS_COMPRESS_G2_UNCHECKED: u32 = derive_keccak256_id!("compressG2Unchecked(bytes)"); // 0x8bf26133 pub const SIG_BLS_VERIFY: u32 = derive_keccak256_id!("verify(bytes,bytes,bytes,bytes,bytes)"); -// 0x52736f7b -pub const SIG_LAST_FINALIZED_EPOCH_P1: u32 = derive_keccak256_id!("lastFinalizedEpochP1()"); -// 0x6a4a209f -pub const SIG_PARTICIPATION: u32 = derive_keccak256_id!("participation(uint64,uint32)"); // 0xa10954fe pub const SIG_RESERVE_BALANCE: u32 = derive_keccak256_id!("reserveBalance()"); // 0x7f3bd56e @@ -276,7 +284,7 @@ pub const SIG_BLS_COMPRESS_G1_UNCHECKED: u32 = derive_keccak256_id!("compressG1U pub const ERR_ALREADY_INITIALIZED: u32 = derive_keccak256_id!("InvalidInitialization()"); pub const ERR_NOT_INITIALIZED: u32 = derive_keccak256_id!("NotInitialized()"); -pub const ERR_ONLY_GOVERNANCE: u32 = derive_keccak256_id!("OnlyGovernanceContract()"); +pub const ERR_ONLY_GOVERNANCE: u32 = derive_keccak256_id!("OnlyGovernance()"); pub const ERR_ZERO_OWNER: u32 = derive_keccak256_id!("ZeroOwner()"); pub const ERR_ZERO_VALIDATOR: u32 = derive_keccak256_id!("ZeroValidator()"); pub const ERR_MALFORMED_INPUT_LENGTH: u32 = derive_keccak256_id!("MalformedInputLength()"); @@ -305,7 +313,7 @@ pub const ERR_PENDING_DELEGATION: u32 = derive_keccak256_id!("PendingDelegation( pub const ERR_STAKING_TOKEN_CALL_FAILED: u32 = derive_keccak256_id!("StakingTokenCallFailed()"); pub const ERR_UNKNOWN_METHOD: u32 = derive_keccak256_id!("UnknownMethod()"); pub const ERR_ONLY_SYSTEM_CALL: u32 = derive_keccak256_id!("OnlySystemCall()"); -pub const ERR_ONLY_LIVENESS_SLASHING: u32 = derive_keccak256_id!("OnlyLivenessSlashing()"); +pub const ERR_ONLY_SELF_CALL: u32 = derive_keccak256_id!("OnlySelfCall()"); pub const ERR_ZERO_VALUE: u32 = derive_keccak256_id!("ZeroValue(string)"); pub const ERR_MAX_ACTIVE_VALIDATORS_EXCEEDED: u32 = derive_keccak256_id!("MaxActiveValidatorsExceeded(uint32,uint32)"); @@ -316,10 +324,10 @@ pub const ERR_UNDELEGATE_WINDOW_TOO_SHORT: u32 = derive_keccak256_id!("UndelegateWindowTooShort(uint256,uint256)"); pub const ERR_SLASH_REPORTER_REWARD_BPS_TOO_HIGH: u32 = derive_keccak256_id!("SlashReporterRewardBpsTooHigh(uint32,uint32)"); -pub const ERR_PARTICIPATION_FLOOR_BPS_TOO_HIGH: u32 = - derive_keccak256_id!("ParticipationFloorBpsTooHigh(uint32,uint32)"); pub const ERR_BLEND_STIPEND_PER_EPOCH_TOO_HIGH: u32 = derive_keccak256_id!("BlendStipendPerEpochTooHigh(uint256,uint256)"); +pub const ERR_MIN_VERDICT_DUE_BLOCKS_TOO_HIGH: u32 = + derive_keccak256_id!("MinVerdictDueBlocksTooHigh(uint32,uint32)"); pub const ERR_INVALID_CLAIM_EPOCH: u32 = derive_keccak256_id!("InvalidClaimEpoch()"); pub const ERR_CONSENSUS_KEYS_ALREADY_SET: u32 = derive_keccak256_id!("ConsensusKeysAlreadySet(address)"); @@ -335,6 +343,8 @@ pub const ERR_SIGNER_INDEX_OUT_OF_RANGE: u32 = pub const ERR_COMMITTEE_LENGTH_MISMATCH: u32 = derive_keccak256_id!("CommitteeLengthMismatch(uint256,uint256)"); pub const ERR_COMMITTEE_TOO_SMALL: u32 = derive_keccak256_id!("CommitteeTooSmall(uint256,uint256)"); +pub const ERR_LEADER_STAKES_LENGTH_MISMATCH: u32 = + derive_keccak256_id!("LeaderStakesLengthMismatch(uint64,uint256,uint256)"); pub const ERR_EPOCH_NOT_YET_COMMITTABLE: u32 = derive_keccak256_id!("EpochNotYetCommittable(uint64,uint64)"); pub const ERR_COMMITTEE_MEMBER_KEYLESS: u32 = @@ -369,8 +379,6 @@ pub const ERR_EQUIVOCATION_COMMITMENT_NOT_MATURE: u32 = derive_keccak256_id!("EquivocationCommitmentNotMature(address,uint64,uint64)"); pub const ERR_INVALID_EQUIVOCATION_PROOF_KIND: u32 = derive_keccak256_id!("InvalidEquivocationProofKind(uint8)"); -pub const ERR_VALIDATOR_NOT_IN_JAIL: u32 = derive_keccak256_id!("ValidatorNotInJail(address)"); -pub const ERR_STILL_IN_JAIL: u32 = derive_keccak256_id!("StillInJail(address)"); pub const BALANCE_COMPACT_PRECISION: U256 = U256::from_limbs([10_000_000_000, 0, 0, 0]); pub const COMMISSION_RATE_MAX: u16 = 3_000; @@ -381,7 +389,24 @@ pub const MIN_COMMITTEE_LENGTH: usize = 1; pub const DEFAULT_UNDELEGATE_PERIOD: u64 = 7; pub const WARMUP_DELAY: u64 = 2; pub const MAX_EPOCHS_PER_CLAIM: u64 = 1_000; -pub const MAX_SETTLE_CATCHUP: u64 = 32; +/// Inherited from the Solidity, where it was sized against measured EVM gas for +/// one settled epoch inside a 12M caller bound. rWasm meters fuel, not gas, so +/// the bound this number encodes has not been measured on this runtime yet. +pub const MAX_SETTLE_CATCHUP: u64 = 4; +/// Exclusions stamped by one epoch close. +/// +/// Deliberately low: a correlated loss of `f` seats is answered over at least +/// eight closes, which gives a healed cause time to clear the verdicts before +/// most stamps land. +pub const MAX_STAMPS_PER_CLOSE: usize = 2; +/// Fuel forwarded to the tolerant stipend leg. +/// +/// The Solidity bound is 12M gas. Fuel is gas scaled by `FUEL_DENOM_RATE`, so +/// passing the gas figure straight into the fuel slot under-provisions the +/// frame twentyfold and turns the leg into a guaranteed `OutOfFuel`. Like +/// `MAX_SETTLE_CATCHUP`, the 12M itself is a measured EVM budget that has not +/// been re-measured against rWasm fuel. +pub const STIPEND_FUEL_CAP: u64 = 12_000_000 * FUEL_DENOM_RATE; pub const EPOCH_COMMITTEE_RETENTION_MARGIN: u64 = 8; pub const MAX_COMMITTEE_LOOKAHEAD_EPOCHS: u64 = 2; pub const BLS_PUBKEY_UNCOMPRESSED_LENGTH: usize = 256; @@ -393,12 +418,14 @@ pub const EQUIVOCATION_PROOF_KIND_FINALIZE: u8 = 1; pub const EQUIVOCATION_PROOF_KIND_NULLIFY_FINALIZE: u8 = 2; pub const EQUIVOCATION_PROOF_KIND_COUNT: u8 = 3; -pub const DEFAULT_FELONY_THRESHOLD: u32 = 1; -pub const DEFAULT_VALIDATOR_JAIL_EPOCH_LENGTH: u32 = 1; pub const DEFAULT_SLASH_REPORTER_REWARD_BPS: u32 = 3_000; pub const MAX_SLASH_REPORTER_REWARD_BPS: u32 = 5_000; -pub const DEFAULT_PARTICIPATION_FLOOR_BPS: u32 = 1_500; -pub const MAX_PARTICIPATION_FLOOR_BPS: u32 = 2_000; +pub const DEFAULT_MIN_VERDICT_DUE_BLOCKS: u32 = 100; +pub const DEFAULT_EXCLUSION_BACKOFF_CAP: u32 = 128; +/// A due-block floor above the epoch length makes `due >= floor` unsatisfiable +/// for every member at any stake, disabling the tier while it still reads as +/// enabled. Bounded absolutely so the check cannot become interval-dependent. +pub const MAX_MIN_VERDICT_DUE_BLOCKS: u32 = 1_000_000; pub const MAX_BLEND_STIPEND_PER_EPOCH: U256 = U256::from_limbs([2_003_764_205_206_896_640, 54_210, 0, 0]); pub const SYSTEM_CALLER: Address = address!("0xfffffffffffffffffffffffffffffffffffffffe"); @@ -411,3 +438,5 @@ pub const INITIALIZER_STORAGE_SLOT: U256 = erc7201_slot!("Fluent.storage.Initial pub const CHAIN_CONFIG_STORAGE_SLOT: U256 = erc7201_slot!("Fluent.storage.ChainConfig"); pub const CONSENSUS_STORAGE_SLOT: U256 = erc7201_slot!("Fluent.storage.Consensus"); pub const STAKING_STORAGE_SLOT: U256 = erc7201_slot!("Fluent.storage.StakingStorage"); +pub const PRODUCTION_LIVENESS_STORAGE_SLOT: U256 = + erc7201_slot!("Fluent.storage.ProductionLiveness"); diff --git a/contracts/staking/src/events.rs b/contracts/staking/src/events.rs index 677ff682c..ac03f23e0 100644 --- a/contracts/staking/src/events.rs +++ b/contracts/staking/src/events.rs @@ -47,6 +47,8 @@ pub struct Undelegated { pub struct ActiveValidatorsLengthChanged { pub prev_value: u32, pub new_value: u32, + /// First epoch the new cap governs committee selection. + pub effective_epoch: u64, } #[derive(Debug, Clone, PartialEq, Eq, Event)] @@ -61,18 +63,6 @@ pub struct DposActivationBlockChanged { pub new_value: u64, } -#[derive(Debug, Clone, PartialEq, Eq, Event)] -pub struct FelonyThresholdChanged { - pub prev_value: u32, - pub new_value: u32, -} - -#[derive(Debug, Clone, PartialEq, Eq, Event)] -pub struct ValidatorJailEpochLengthChanged { - pub prev_value: u32, - pub new_value: u32, -} - #[derive(Debug, Clone, PartialEq, Eq, Event)] pub struct SlashReporterRewardBpsChanged { pub prev_value: u32, @@ -85,18 +75,6 @@ pub struct SlashFundAddressChanged { pub new_value: Address, } -#[derive(Debug, Clone, PartialEq, Eq, Event)] -pub struct ParticipationFloorBpsChanged { - pub prev_value: u32, - pub new_value: u32, -} - -#[derive(Debug, Clone, PartialEq, Eq, Event)] -pub struct ParticipationJailDisabledChanged { - pub prev_value: bool, - pub new_value: bool, -} - #[derive(Debug, Clone, PartialEq, Eq, Event)] pub struct BlendStipendPerEpochChanged { pub prev_value: U256, @@ -145,6 +123,79 @@ pub struct BlendReserveChanged { pub new_value: Address, } +#[derive(Debug, Clone, PartialEq, Eq, Event)] +pub struct MinVerdictDueBlocksChanged { + pub prev_value: u32, + pub new_value: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Event)] +pub struct ExclusionBackoffCapChanged { + pub prev_value: u32, + pub new_value: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Event)] +pub struct ProductionLivenessDisabledChanged { + pub prev_value: bool, + pub new_value: bool, +} + +#[derive(Event)] +pub struct ProductionExclusionApplied { + #[indexed] + pub validator: Address, + pub bite_epoch: u64, +} + +#[derive(Event)] +pub struct ProductionExclusionReleased { + #[indexed] + pub validator: Address, + pub bite_epoch: u64, +} + +/// Mandatory rather than diagnostic: the partial-epoch taint is derived from +/// the block count instead of stored, so one unrecorded block silently costs a +/// whole epoch its verdicts while the tier still reads as enabled. +#[derive(Event)] +pub struct PartialEpoch { + #[indexed] + pub epoch: u64, + pub recorded: u32, + pub expected: u32, +} + +/// Emitted per failing member whether or not a stamp follows; a verdict without +/// a stamp is the normal case. +#[derive(Event)] +pub struct ProductionVerdictFailed { + #[indexed] + pub epoch: u64, + #[indexed] + pub validator: Address, + pub produced: u32, + pub due: U256, +} + +/// More than `f` members failing for the first time in one epoch reads as an +/// environment rather than as individual faults. No stamps that close. +#[derive(Event)] +pub struct CorrelatedFailureEpoch { + #[indexed] + pub epoch: u64, + pub new_failures: U256, + pub tolerance: U256, +} + +/// The liveness legs of this close have already committed; the reward cursor +/// did not advance, so the next close retries contiguously. +#[derive(Event)] +pub struct StipendLegSkipped { + #[indexed] + pub epoch: u64, +} + #[derive(Event)] pub struct ValidatorOwnerClaimed { #[indexed] @@ -203,13 +254,6 @@ pub struct EpochCommitteeCommitted { pub committee: Vec
, } -#[derive(Event)] -pub struct ValidatorReleased { - #[indexed] - pub validator: Address, - pub epoch: u64, -} - #[derive(Event)] pub struct ValidatorJailed { #[indexed] @@ -217,23 +261,6 @@ pub struct ValidatorJailed { pub epoch: u64, } -#[derive(Event)] -pub struct ValidatorSlashed { - #[indexed] - pub validator: Address, - pub slashes: u32, - pub epoch: u64, -} - -#[derive(Event)] -pub struct LivenessJailSkippedHaltGuard { - #[indexed] - pub validator: Address, - pub epoch: u64, - pub active_set_size: U256, - pub quorum_floor: U256, -} - #[derive(Event)] pub struct EquivocationReportCommitted { #[indexed] diff --git a/contracts/staking/src/initializer.rs b/contracts/staking/src/initializer.rs index 81cbdc35f..994963566 100644 --- a/contracts/staking/src/initializer.rs +++ b/contracts/staking/src/initializer.rs @@ -16,7 +16,7 @@ use crate::{ }; use fluentbase_sdk::{Address, ExitCode, SharedAPI, U256}; -/// Public handler `0x4b4b21a5` (`initialize`). +/// Public handler `0xd86555fe` (`initialize`). /// /// Atomically initializes chain configuration, dependencies, and genesis validators. pub fn initialize(sdk: &mut SDK, input: &[u8]) -> Result<(), ExitCode> { diff --git a/contracts/staking/src/lib.rs b/contracts/staking/src/lib.rs index 50dfebbc0..c8d77f22e 100644 --- a/contracts/staking/src/lib.rs +++ b/contracts/staking/src/lib.rs @@ -14,6 +14,7 @@ mod consensus; mod consts; mod events; mod initializer; +mod liveness; mod math; mod staking; mod storage; @@ -44,31 +45,25 @@ pub fn main_entry(sdk: &mut SDK) -> Result<(), ExitCode> { SIG_INITIALIZE => initializer::initialize(sdk, params), // ChainConfig - SIG_DEFAULT_PARTICIPATION_FLOOR_BPS => config::default_participation_floor_bps(sdk), SIG_DEFAULT_SLASH_REPORTER_BPS => config::default_slash_reporter_bps(sdk), SIG_MAX_ACTIVE_VALIDATORS => config::max_active_validators(sdk), SIG_MAX_BLEND_STIPEND_PER_EPOCH => config::max_blend_stipend_per_epoch(sdk), - SIG_MAX_PARTICIPATION_FLOOR_BPS => config::max_participation_floor_bps(sdk), SIG_MAX_SLASH_REPORTER_BPS => config::max_slash_reporter_bps(sdk), + SIG_DEFAULT_MIN_VERDICT_DUE_BLOCKS => config::default_min_verdict_due_blocks(sdk), + SIG_DEFAULT_EXCLUSION_BACKOFF_CAP => config::default_exclusion_backoff_cap(sdk), + SIG_MAX_MIN_VERDICT_DUE_BLOCKS => config::max_min_verdict_due_blocks(sdk), SIG_GET_STAKING_TOKEN => config::get_staking_token(sdk), SIG_GET_ACTIVE_VALIDATORS_LENGTH => config::get_active_validators_length(sdk), + SIG_GET_ACTIVE_VALIDATORS_LENGTH_AT => config::get_active_validators_length_at(sdk, params), SIG_GET_EPOCH_BLOCK_INTERVAL => config::get_epoch_block_interval(sdk), SIG_GET_DPOS_ACTIVATION_BLOCK => config::get_dpos_activation_block(sdk), SIG_GET_UNDELEGATE_PERIOD => config::get_undelegate_period(sdk), SIG_GET_MIN_VALIDATOR_STAKE_AMOUNT => config::get_min_validator_stake_amount(sdk), SIG_GET_MIN_STAKING_AMOUNT => config::get_min_staking_amount(sdk), - SIG_GET_FELONY_THRESHOLD => config::get_felony_threshold(sdk), - SIG_SET_FELONY_THRESHOLD => config::set_felony_threshold(sdk, params), - SIG_GET_VALIDATOR_JAIL_EPOCH_LENGTH => config::get_validator_jail_epoch_length(sdk), - SIG_SET_VALIDATOR_JAIL_EPOCH_LENGTH => config::set_validator_jail_epoch_length(sdk, params), SIG_GET_SLASH_REPORTER_REWARD_BPS => config::get_slash_reporter_reward_bps(sdk), SIG_SET_SLASH_REPORTER_REWARD_BPS => config::set_slash_reporter_reward_bps(sdk, params), SIG_GET_SLASH_FUND_ADDRESS => config::get_slash_fund_address(sdk), SIG_SET_SLASH_FUND_ADDRESS => config::set_slash_fund_address(sdk, params), - SIG_GET_PARTICIPATION_FLOOR_BPS => config::get_participation_floor_bps(sdk), - SIG_SET_PARTICIPATION_FLOOR_BPS => config::set_participation_floor_bps(sdk, params), - SIG_GET_PARTICIPATION_JAIL_DISABLED => config::get_participation_jail_disabled(sdk), - SIG_SET_PARTICIPATION_JAIL_DISABLED => config::set_participation_jail_disabled(sdk, params), SIG_GET_BLEND_STIPEND_PER_EPOCH => config::get_blend_stipend_per_epoch(sdk), SIG_SET_BLEND_STIPEND_PER_EPOCH => config::set_blend_stipend_per_epoch(sdk, params), SIG_SET_ACTIVE_VALIDATORS_LENGTH => config::set_active_validators_length(sdk, params), @@ -85,6 +80,23 @@ pub fn main_entry(sdk: &mut SDK) -> Result<(), ExitCode> { SIG_SET_LIVENESS_SLASHING => config::set_liveness_slashing(sdk, params), SIG_GET_BLEND_RESERVE => config::get_blend_reserve(sdk), SIG_SET_BLEND_RESERVE => config::set_blend_reserve(sdk, params), + SIG_GET_MIN_VERDICT_DUE_BLOCKS => config::get_min_verdict_due_blocks(sdk), + SIG_SET_MIN_VERDICT_DUE_BLOCKS => config::set_min_verdict_due_blocks(sdk, params), + SIG_GET_EXCLUSION_BACKOFF_CAP => config::get_exclusion_backoff_cap(sdk), + SIG_SET_EXCLUSION_BACKOFF_CAP => config::set_exclusion_backoff_cap(sdk, params), + SIG_GET_PRODUCTION_LIVENESS_DISABLED => config::get_production_liveness_disabled(sdk), + SIG_SET_PRODUCTION_LIVENESS_DISABLED => { + config::set_production_liveness_disabled(sdk, params) + } + + // ProductionLiveness + SIG_GET_PRODUCTION_STATS => liveness::get_production_stats(sdk, params), + SIG_BLOCKS_IN_EPOCH => liveness::blocks_in_epoch(sdk, params), + SIG_PRODUCED_AT => liveness::produced_at(sdk, params), + SIG_PENDING_EXCLUSIONS => liveness::pending_exclusions(sdk), + SIG_READMIT_AT_EPOCH => liveness::readmit_at_epoch(sdk, params), + SIG_LAST_PROCESSED_BLOCK => liveness::last_processed_block(sdk), + SIG_RECORD_PRODUCTION => liveness::record_production(sdk, params), // Staking SIG_CURRENT_EPOCH => staking::current_epoch_read(sdk), @@ -121,6 +133,7 @@ pub fn main_entry(sdk: &mut SDK) -> Result<(), ExitCode> { SIG_REDELEGATE_DELEGATOR_FEE => staking::redelegate_delegator_fee(sdk, params), SIG_GET_EPOCH_REWARDS => staking::get_epoch_rewards(sdk, params), SIG_SETTLE_EPOCH_STIPEND => staking::settle_epoch_stipend(sdk, params), + SIG_SETTLE_EPOCH_STIPEND_FROM => staking::settle_epoch_stipend_from(sdk, params), // Consensus SIG_GET_CONSENSUS_KEYS => consensus::get_consensus_keys(sdk, params), @@ -137,9 +150,6 @@ pub fn main_entry(sdk: &mut SDK) -> Result<(), ExitCode> { SIG_GET_EPOCH_COMMITTEE_WITH_STAKES => { consensus::get_epoch_committee_with_stakes(sdk, params) } - SIG_RELEASE_VALIDATOR_FROM_JAIL => consensus::release_validator_from_jail(sdk, params), - SIG_READMIT_EXPIRED_JAILS => consensus::readmit_expired_jails(sdk, params), - SIG_SLASH => consensus::slash(sdk, params), SIG_COMMIT_EQUIVOCATION_REPORT => consensus::commit_report(sdk, params), SIG_COMPUTE_EQUIVOCATION_REPORT_COMMITMENT => { consensus::compute_report_commitment(sdk, params) diff --git a/contracts/staking/src/liveness.rs b/contracts/staking/src/liveness.rs new file mode 100644 index 000000000..f9128bef7 --- /dev/null +++ b/contracts/staking/src/liveness.rs @@ -0,0 +1,592 @@ +//! Block-production accounting for the production-liveness tier. + +use crate::{ + consts::*, + events, math, staking, + storage::{chain_config_storage, consensus_storage, production_liveness_storage}, + types::{ + AddressCommand, EpochSignerCommand, RecordProductionCommand, U64Command, + ValidatorEpochCommand, + }, + util::{ + current_epoch, current_epoch_at_block, decode, ensure_initialized, ensure_mutable, + ensure_non_payable, revert, revert_with, write_abi, + }, +}; +use alloc::{vec, vec::Vec}; +use fluentbase_sdk::{ + bytes::BytesMut, codec::SolidityABI, Address, ContextReader, ExitCode, SharedAPI, U256, +}; + +/// Epoch at whose close `validator` is released, or `0` when not excluded. +pub(crate) fn readmit_at_epoch_of( + sdk: &SDK, + validator: Address, +) -> Result { + production_liveness_storage() + .validators_accessor() + .entry(validator) + .readmit_at_epoch_accessor() + .get_checked(sdk) +} + +fn committee_index_of( + sdk: &SDK, + validator: Address, + epoch: u64, +) -> Result, ExitCode> { + let committee = consensus_storage().epoch_committees_accessor().entry(epoch); + let len = committee.len_checked(sdk)?; + for index in 0..len { + if committee.at(index).get_checked(sdk)? == validator { + return Ok(Some(index as u32)); + } + } + Ok(None) +} + +/// Public handler `0x8244a2c2` (`recordProduction`). +/// +/// Records one block's producer and, when the block crosses an epoch boundary, +/// closes the epoch that just ended. +/// +/// `block_number` is an idempotency key, never an epoch tag: the epoch is +/// derived from it, because a height is deterministic over agreed state while a +/// proposer-supplied tag is not. +pub fn record_production(sdk: &mut SDK, input: &[u8]) -> Result<(), ExitCode> { + ensure_non_payable(sdk)?; + ensure_mutable(sdk)?; + ensure_initialized(sdk)?; + if sdk.context().contract_caller() != SYSTEM_CALLER { + return revert(sdk, ERR_ONLY_SYSTEM_CALL); + } + let command = decode::(input)?; + let storage = production_liveness_storage(); + let last_processed = storage.last_processed_block_accessor().get_checked(sdk)?; + if command.block_number <= last_processed { + return Ok(()); + } + // Read before the belt overwrites the height. The epoch cursor has no + // storage of its own — it is a pure function of the recorded block, and two + // cursors obliged to agree can disagree. + let previous_epoch = current_epoch_at_block(sdk, last_processed)?; + let epoch = current_epoch_at_block(sdk, command.block_number)?; + storage + .last_processed_block_accessor() + .set_checked(sdk, command.block_number)?; + // The close reads the counters of the epoch that ended; the credit below is + // keyed by the one this block starts, so the two never touch the same key. + // Keeping the close first is a robustness choice, not an invariant the key + // scheme depends on. + if epoch > previous_epoch { + close_epoch(sdk, previous_epoch)?; + } + + // Length only, never the committee: this runs on every block, and + // materializing 51 members with their keys costs orders of magnitude more + // than the whole per-block budget. + let committee = consensus_storage().epoch_committees_accessor().entry(epoch); + let committee_size = committee.len_checked(sdk)?; + // Not yet committed: park the block. It is neither counted nor credited, + // which keeps `sum(produced) == blocks_in_epoch` true by construction and + // leaves the epoch short of the interval, i.e. tainted. + if committee_size == 0 { + return Ok(()); + } + // A belt the vote already guaranteed. Reaching it means the off-chain index + // space and the committed committee order have diverged, which no verify can + // catch; the block is dropped and the epoch's shortfall surfaces it. + if u64::from(command.leader_index) >= committee_size { + return Ok(()); + } + + let credited = storage + .produced_accessor() + .entry(epoch) + .entry(u32::from(command.leader_index)); + credited.set_checked( + sdk, + credited + .get_checked(sdk)? + .checked_add(1) + .ok_or(ExitCode::IntegerOverflow)?, + )?; + let recorded = storage.blocks_in_epoch_accessor().entry(epoch); + recorded.set_checked( + sdk, + recorded + .get_checked(sdk)? + .checked_add(1) + .ok_or(ExitCode::IntegerOverflow)?, + )?; + let producer = committee + .at(u64::from(command.leader_index)) + .get_checked(sdk)?; + let record = storage.validators_accessor().entry(producer); + let total = record.total_produced_accessor(); + total.set_checked( + sdk, + total + .get_checked(sdk)? + .checked_add(1) + .ok_or(ExitCode::IntegerOverflow)?, + )?; + record + .last_produced_epoch_p1_accessor() + .set_checked(sdk, epoch.checked_add(1).ok_or(ExitCode::IntegerOverflow)?) +} + +/// Close `epoch`: releases, verdicts, stipend. +/// +/// Three legs in that order and with three different failure policies. Releases +/// first, so an expiring exclusion cannot be held hostage by the guard. +/// Verdicts second and fail-loud, because a rolled-back no-op would retry every +/// block forever with a warning as its only symptom. The stipend last and +/// tolerant, because it is the one leg where a frozen payment is preferable to +/// any chance of a frozen chain. +fn close_epoch(sdk: &mut SDK, epoch: u64) -> Result<(), ExitCode> { + let config = chain_config_storage(); + let current = current_epoch(sdk)?; + let disabled = config + .production_liveness_disabled_accessor() + .get_checked(sdk)?; + + // Unconditional on the correlation guard: tying releases to it would freeze + // them during exactly the outage they exist for, and would make the + // exclusion duration non-deterministic against a fixed ladder. Frozen with + // the rest of the verdict state under the kill switch, so no exclusion + // expires unnoticed while the tier is off. + if !disabled { + release_expired(sdk, current)?; + } + + let recorded = production_liveness_storage() + .blocks_in_epoch_accessor() + .entry(epoch) + .get_checked(sdk)?; + let interval = config.epoch_block_interval_accessor().get_checked(sdk)?; + + // The taint is derived, not stored: epochs are height-defined and every + // finalized block carries a record, so a complete epoch records exactly + // `interval` of them. That is wider than a stored flag — it also catches an + // executor skip and an out-of-range belt drop, both of which deflate the + // denominator while tainting nothing. + if u64::from(recorded) != interval { + events::PartialEpoch { + epoch, + recorded, + expected: interval as u32, + } + .emit(sdk)?; + } else if !disabled { + judge(sdk, epoch, current, recorded)?; + } + + // An epoch that was never recorded at all is reachable and must not draw a + // full pot for no work. + if recorded > 0 { + settle_stipend_leg(sdk, epoch)?; + } + Ok(()) +} + +/// Release every exclusion whose term has expired. Bounded by `f`. +fn release_expired(sdk: &mut SDK, current: u64) -> Result<(), ExitCode> { + let storage = production_liveness_storage(); + let pending = storage.pending_exclusions_accessor(); + let mut index = 0; + while index < pending.len_checked(sdk)? { + let validator = pending.at(index).get_checked(sdk)?; + let readmit = storage + .validators_accessor() + .entry(validator) + .readmit_at_epoch_accessor() + .get_checked(sdk)?; + if readmit == 0 || readmit > current { + index += 1; + continue; + } + // The Active/tombstone guard lives inside the callee, which owns that + // state. The record clears either way: a released-but-not-restamped + // entry would pin a slot in the `<= f` concurrent budget forever. + // `kick_count` survives — the ladder counts episodes across a + // validator's whole life. + staking::release_production_exclusion(sdk, validator)?; + storage + .validators_accessor() + .entry(validator) + .readmit_at_epoch_accessor() + .set_checked(sdk, 0)?; + let last = pending.len_checked(sdk)? - 1; + if index != last { + let tail = pending.at(last).get_checked(sdk)?; + pending.at(index).set_checked(sdk, tail)?; + } + pending.pop_checked(sdk)?; + // Swap-pop slid a new element into `index`, so do not advance. + } + Ok(()) +} + +/// The verdict sweep for a fully recorded epoch. +/// +/// `due_i = w_i / W * recorded` is an expectation from on-chain stake, not a +/// replay of the leader lottery. `w_i` is the weight frozen at commit from the +/// selection epoch — the exact weight the lottery drew with — which is what +/// makes the expectation exact rather than approximate. Both predicates are +/// cross-multiplied: no division, no fixed point. +/// +/// Failing below half of `due` means the member's per-slot success rate is +/// below half the stake-weighted fleet average, so uniform degradation moves +/// every member together and fails nobody at any severity, while a dead member +/// has `produced == 0` and fails at every dilution level. +fn judge( + sdk: &mut SDK, + epoch: u64, + current: u64, + recorded: u32, +) -> Result<(), ExitCode> { + let consensus = consensus_storage(); + let committee = consensus.epoch_committees_accessor().entry(epoch); + let member_count = committee.len_checked(sdk)?; + if member_count == 0 { + return Ok(()); + } + let frozen = consensus.leader_stakes_accessor().entry(epoch); + let frozen_count = frozen.len_checked(sdk)?; + if frozen_count != member_count { + return revert_with( + sdk, + ERR_LEADER_STAKES_LENGTH_MISMATCH, + &( + epoch, + U256::from(member_count), + U256::from(frozen_count), + ), + ); + } + + let mut members = Vec::with_capacity(member_count as usize); + let mut weights = Vec::with_capacity(member_count as usize); + let mut total_weight = U256::ZERO; + for index in 0..member_count { + members.push(committee.at(index).get_checked(sdk)?); + let weight = math::expand_balance(frozen.at(index).get_checked(sdk)?); + total_weight = total_weight + .checked_add(weight) + .ok_or(ExitCode::IntegerOverflow)?; + weights.push(weight); + } + if total_weight.is_zero() { + return Ok(()); + } + + let floor = U256::from( + chain_config_storage() + .min_verdict_due_blocks_accessor() + .get_checked(sdk)?, + ); + let floor_scaled = floor + .checked_mul(total_weight) + .ok_or(ExitCode::IntegerOverflow)?; + let recorded_blocks = U256::from(recorded); + let storage = production_liveness_storage(); + let mut failed = vec![false; member_count as usize]; + let mut failures = 0usize; + let mut new_failures = 0usize; + + for (index, weight) in weights.iter().enumerate() { + let due_scaled = weight + .checked_mul(recorded_blocks) + .ok_or(ExitCode::IntegerOverflow)?; + // A zero-weight member is unjudgeable, and that is coherent — it was + // never due a slot. Frozen weights make this a normal transient for a + // newly seated validator, not only the signature of an abandoned seat. + if due_scaled < floor_scaled { + continue; + } + let produced = storage + .produced_accessor() + .entry(epoch) + .entry(index as u32) + .get_checked(sdk)?; + if U256::from(produced) + .checked_mul(U256::from(2)) + .ok_or(ExitCode::IntegerOverflow)? + .checked_mul(total_weight) + .ok_or(ExitCode::IntegerOverflow)? + >= due_scaled + { + continue; + } + + failed[index] = true; + failures += 1; + let validator = members[index]; + events::ProductionVerdictFailed { + epoch, + validator, + produced, + due: due_scaled / total_weight, + } + .emit(sdk)?; + + // Counted as new only if the member did not also fail the previous epoch + // and is not already being answered. A chronic failer is evidence of + // itself, not of an environment; without that test `f` colluders plus + // one donated honest failure would hold the guard on forever. + // + // `last_failed_epoch_p1` stores epoch+1, so "failed E−1" is `p1 == + // epoch` — but only when `p1` is a real record. At epoch 0 the + // never-failed sentinel collides with the encoding of a nonexistent + // epoch −1, and a bare inequality would read a first-ever failure as a + // repeat. + let record = storage.validators_accessor().entry(validator); + let previous_p1 = record.last_failed_epoch_p1_accessor().get_checked(sdk)?; + let failed_previous_epoch = previous_p1 != 0 && previous_p1 == epoch; + if !failed_previous_epoch && record.readmit_at_epoch_accessor().get_checked(sdk)? == 0 { + new_failures += 1; + } + } + + // Written for every failer on both paths, and only after the newness test + // has read the old values. Writing it just when the guard fires would leave + // a chronic failer's bit stale, so it would pass the newness test forever. + let stamp_epoch_p1 = epoch.checked_add(1).ok_or(ExitCode::IntegerOverflow)?; + for (index, did_fail) in failed.iter().enumerate() { + if *did_fail { + storage + .validators_accessor() + .entry(members[index]) + .last_failed_epoch_p1_accessor() + .set_checked(sdk, stamp_epoch_p1)?; + } + } + + let tolerance = math::fault_tolerance(member_count as usize); + if new_failures > tolerance { + // An environment breaks suddenly, so it shows up as a jump in first-time + // failures. One epoch of amnesty, not an open-ended shield: from the + // second epoch its members are no longer new. + events::CorrelatedFailureEpoch { + epoch, + new_failures: U256::from(new_failures), + tolerance: U256::from(tolerance), + } + .emit(sdk)?; + return Ok(()); + } + if failures == 0 { + return Ok(()); + } + stamp(sdk, current, &members, &mut failed, tolerance) +} + +/// At most `MAX_STAMPS_PER_CLOSE` stamps per close, never more than `f` +/// concurrent. +/// +/// The order is kick-count descending then address ascending — stated rather +/// than left to storage iteration order, which is deterministic but is an +/// accident a future layout change could silently alter with no spec to violate. +fn stamp( + sdk: &mut SDK, + current: u64, + members: &[Address], + failed: &mut [bool], + tolerance: usize, +) -> Result<(), ExitCode> { + let cap = chain_config_storage() + .exclusion_backoff_cap_accessor() + .get_checked(sdk)?; + let storage = production_liveness_storage(); + let pending = storage.pending_exclusions_accessor(); + for _ in 0..MAX_STAMPS_PER_CLOSE { + if pending.len_checked(sdk)? as usize >= tolerance { + break; + } + // Two-pass max-selection rather than a sort: only two picks are needed. + let mut best: Option = None; + for (index, did_fail) in failed.iter().enumerate() { + if !*did_fail { + continue; + } + let record = storage.validators_accessor().entry(members[index]); + if record.readmit_at_epoch_accessor().get_checked(sdk)? != 0 { + continue; + } + let Some(leader) = best else { + best = Some(index); + continue; + }; + let leader_kicks = storage + .validators_accessor() + .entry(members[leader]) + .kick_count_accessor() + .get_checked(sdk)?; + let kicks = record.kick_count_accessor().get_checked(sdk)?; + if kicks > leader_kicks || (kicks == leader_kicks && members[index] < members[leader]) { + best = Some(index); + } + } + let Some(best) = best else { + break; + }; + // Consumed either way, so a refusal cannot spin. + failed[best] = false; + // A refused stamp must leave no trace: no ladder increment, no queue + // entry. Otherwise the backoff advances for an exclusion that never was. + if !staking::apply_production_exclusion(sdk, members[best])? { + continue; + } + let record = storage.validators_accessor().entry(members[best]); + let episodes = record + .kick_count_accessor() + .get_checked(sdk)? + .checked_add(1) + .ok_or(ExitCode::IntegerOverflow)?; + record.kick_count_accessor().set_checked(sdk, episodes)?; + // Measured from the current epoch, not the judged one. The two coincide + // when the close runs on the first block of the next epoch, but a close + // can run late, and the current-epoch form keeps the ladder length + // intact instead of silently shortening the exclusion. + let duration = u64::from(core::cmp::min(episodes, cap)); + record.readmit_at_epoch_accessor().set_checked( + sdk, + current + .checked_add(duration) + .ok_or(ExitCode::IntegerOverflow)?, + )?; + pending.push_checked(sdk, members[best])?; + } + Ok(()) +} + +/// Run the stipend inside a fuel-capped self-call. +/// +/// The host builds a real frame with its own journal checkpoint, so a revert +/// inside it discards only that frame's writes — every release and verdict +/// above has already landed and survives. Failure arrives as a status and never +/// as an unwind: `unwrap` on the result would abort the outer frame under +/// `panic = "abort"`, which is the exact opposite of tolerance. `OutOfFuel` and +/// a revert are distinguishable and both are tolerated. +/// +/// The event is emitted from this frame deliberately: a log written inside the +/// discarded frame goes with it, and a system call leaves no receipt to read the +/// failure from instead. +fn settle_stipend_leg(sdk: &mut SDK, epoch: u64) -> Result<(), ExitCode> { + let mut params = BytesMut::new(); + SolidityABI::::encode(&U64Command { value: epoch }, &mut params, 0) + .map_err(|_| ExitCode::MalformedBuiltinParams)?; + let mut input = SIG_SETTLE_EPOCH_STIPEND_FROM.to_be_bytes().to_vec(); + input.extend_from_slice(¶ms); + let own_address = sdk.context().contract_address(); + let result = sdk.call(own_address, U256::ZERO, &input, Some(STIPEND_FUEL_CAP)); + if !result.status.is_ok() { + events::StipendLegSkipped { epoch }.emit(sdk)?; + } + Ok(()) +} + +/// Public handler `0x8e948ac1` (`getProductionStats`). +/// +/// Returns the delegator-facing production record for `validator` at `epoch`. +pub fn get_production_stats(sdk: &mut SDK, input: &[u8]) -> Result<(), ExitCode> { + ensure_non_payable(sdk)?; + // Reads the committee, so it follows the consensus readers rather than the + // config getters: all-zeros must not be indistinguishable from uninitialized. + ensure_initialized(sdk)?; + let command = decode::(input)?; + let epoch = command.before_epoch; + let storage = production_liveness_storage(); + let record = storage.validators_accessor().entry(command.validator); + let produced_this_epoch = match committee_index_of(sdk, command.validator, epoch)? { + Some(index) => storage + .produced_accessor() + .entry(epoch) + .entry(index) + .get_checked(sdk)?, + None => 0, + }; + let result = ( + produced_this_epoch, + record.total_produced_accessor().get_checked(sdk)?, + record + .last_produced_epoch_p1_accessor() + .get_checked(sdk)? + .saturating_sub(1), + record + .last_failed_epoch_p1_accessor() + .get_checked(sdk)? + .saturating_sub(1), + record.kick_count_accessor().get_checked(sdk)?, + record.readmit_at_epoch_accessor().get_checked(sdk)?, + ); + write_abi(sdk, &result) +} + +/// Public handler `0xf06be669` (`blocksInEpoch`). +/// +/// Returns the number of blocks recorded for `epoch`. +pub fn blocks_in_epoch(sdk: &mut SDK, input: &[u8]) -> Result<(), ExitCode> { + ensure_non_payable(sdk)?; + let epoch = decode::(input)?.value; + write_abi( + sdk, + &production_liveness_storage() + .blocks_in_epoch_accessor() + .entry(epoch) + .get_checked(sdk)?, + ) +} + +/// Public handler `0x91c7d453` (`producedAt`). +/// +/// Returns the blocks credited to a committee index in `epoch`. +pub fn produced_at(sdk: &mut SDK, input: &[u8]) -> Result<(), ExitCode> { + ensure_non_payable(sdk)?; + let command = decode::(input)?; + write_abi( + sdk, + &production_liveness_storage() + .produced_accessor() + .entry(command.epoch) + .entry(command.signer_idx) + .get_checked(sdk)?, + ) +} + +/// Public handler `0xaef690f9` (`pendingExclusions`). +/// +/// Returns the validators currently serving an exclusion. +pub fn pending_exclusions(sdk: &mut SDK) -> Result<(), ExitCode> { + ensure_non_payable(sdk)?; + let entries = production_liveness_storage().pending_exclusions_accessor(); + let len = entries.len_checked(sdk)?; + let mut result = Vec::with_capacity(len as usize); + for index in 0..len { + result.push(entries.at(index).get_checked(sdk)?); + } + write_abi(sdk, &result) +} + +/// Public handler `0x32066046` (`readmitAtEpoch`). +/// +/// Returns the epoch at whose close `validator` is released from exclusion. +pub fn readmit_at_epoch(sdk: &mut SDK, input: &[u8]) -> Result<(), ExitCode> { + ensure_non_payable(sdk)?; + let validator = decode::(input)?.value; + write_abi(sdk, &readmit_at_epoch_of(sdk, validator)?) +} + +/// Public handler `0x33de61d2` (`lastProcessedBlock`). +/// +/// Returns the most recent block for which production was recorded. +pub fn last_processed_block(sdk: &mut SDK) -> Result<(), ExitCode> { + ensure_non_payable(sdk)?; + write_abi( + sdk, + &production_liveness_storage() + .last_processed_block_accessor() + .get_checked(sdk)?, + ) +} diff --git a/contracts/staking/src/math.rs b/contracts/staking/src/math.rs index 32c54d036..ca4935bc7 100644 --- a/contracts/staking/src/math.rs +++ b/contracts/staking/src/math.rs @@ -25,6 +25,18 @@ pub fn narrow_reward(amount: U256) -> Option { U96::checked_from_limbs_slice(amount.as_limbs()) } +/// Simplex fault tolerance `f = ⌊(n−1)/3⌋`. +/// +/// Kept byte-equal to the off-chain consensus so the correlation guard and the +/// concurrent-exclusion ceiling cannot disagree with it. +pub fn fault_tolerance(n: usize) -> usize { + if n == 0 { + 0 + } else { + (n - 1) / 3 + } +} + /// Map a block to its activation-relative epoch, clamping pre-activation blocks. pub fn epoch_at_block(block_number: u64, activation_block: u64, interval: u64) -> Option { if interval == 0 { diff --git a/contracts/staking/src/staking.rs b/contracts/staking/src/staking.rs index 0452cf0f6..1ba4da5ed 100644 --- a/contracts/staking/src/staking.rs +++ b/contracts/staking/src/staking.rs @@ -1,10 +1,11 @@ //! Staking ownership, epoch reads, and validator lifecycle methods. use crate::{ + config::active_validators_length_at, consensus::{store_consensus_keys, verify_consensus_keys}, consts::*, - events, math, - storage::{chain_config_storage, consensus_storage, staking_storage, ValidatorSnapshotStorage}, + events, liveness, math, + storage::{chain_config_storage, consensus_storage, production_liveness_storage, staking_storage, ValidatorSnapshotStorage}, types::{ AddValidatorCommand, AddressAmountCommand, AddressCommand, AddressU16Command, RegisterValidatorCommand, TwoAddressesCommand, U64Command, ValidatorBlockCommand, @@ -256,17 +257,111 @@ pub(crate) fn selection_candidates_at( Ok(candidates) } +/// Number of roster members that could actually be seated at `epoch`. +/// +/// This counts the population `top_k_by_stake_at` cuts from, not the one +/// `selection_candidates_at` returns: the Rust drops candidates below the +/// minimum self-stake before the cut, which the Solidity does not. Counting +/// visibility alone would over-report, and the exclusion rule would then hand +/// out a stamp whose seat has no replacement — destroying it rather than +/// rotating it. +/// +/// Measured rather than materialized because it runs at every epoch close. +pub(crate) fn count_selection_visible_at( + sdk: &SDK, + epoch: u64, +) -> Result { + let roster = staking_storage().selection_roster_accessor(); + let len = roster.len_checked(sdk)?; + let mut visible = 0; + for index in 0..len { + let validator = roster.at(index).get_checked(sdk)?; + if selection_visible_at(sdk, validator, epoch)? + && validator_has_minimum_self_stake_at(sdk, validator, epoch)? + { + visible += 1; + } + } + Ok(visible) +} + +/// Stamp `validator` selection-invisible from the next epoch onward. +/// +/// Refuses — returning `false`, never reverting — when the validator is already +/// invisible at the bite epoch, or when the visible pool does not strictly +/// exceed that epoch's cap. Selection takes `min(cap, visible)` with no +/// backfill floor, so excluding the marginal candidate would shrink the +/// committee instead of replacing a seat. A revert is not available here: the +/// caller must be able to leave no trace of a refusal, and reverting would halt +/// the chain over a condition that is normal on a small network. +/// +/// Best-effort by construction: the stamp bites two selection epochs after this +/// check, and registrations in between can invalidate it either way. +pub(crate) fn apply_production_exclusion( + sdk: &mut SDK, + validator: Address, +) -> Result { + let bite_epoch = next_epoch(sdk)?; + if !selection_visible_at(sdk, validator, bite_epoch)? { + return Ok(false); + } + // No "stamps already issued this close" term: an earlier stamp in the same + // close is already invisible at `bite_epoch`, so the count excludes it. + if count_selection_visible_at(sdk, bite_epoch)? <= active_validators_length_at(sdk, bite_epoch)? + { + return Ok(false); + } + set_selection_visible(sdk, validator, false, current_epoch(sdk)?)?; + events::ProductionExclusionApplied { + validator, + bite_epoch, + } + .emit(sdk)?; + Ok(true) +} + +/// Restore selection visibility at the end of an exclusion. +/// +/// A silent no-op for a tombstoned or non-Active validator. The selection +/// filter is the visibility stamp and nothing else, so a blind re-stamp would +/// permanently re-seat a slashed equivocator. +pub(crate) fn release_production_exclusion( + sdk: &mut SDK, + validator: Address, +) -> Result<(), ExitCode> { + if consensus_storage() + .tombstoned_accessor() + .entry(validator) + .get_checked(sdk)? + { + return Ok(()); + } + if validator_status(sdk, validator)? != STATUS_ACTIVE { + return Ok(()); + } + let bite_epoch = next_epoch(sdk)?; + set_selection_visible(sdk, validator, true, current_epoch(sdk)?)?; + events::ProductionExclusionReleased { + validator, + bite_epoch, + } + .emit(sdk) +} + pub(crate) fn selected_validators_at( sdk: &SDK, epoch: u64, ) -> Result, ExitCode> { let candidates = selection_candidates_at(sdk, epoch)?; - let cap = chain_config_storage() - .active_validators_length_accessor() - .get_checked(sdk)? as usize; + let cap = active_validators_length_at(sdk, epoch)? as usize; top_k_by_stake_at(sdk, candidates, epoch, cap) } +/// Live committee view. +/// +/// Deliberately reads the scalar cap, not the epoch-addressed one: this answers +/// "who would be selected right now", so epoch purity neither holds here nor is +/// claimed for it. Only `selected_validators_at` feeds committee commits. pub(crate) fn selected_validators(sdk: &SDK) -> Result, ExitCode> { let storage = staking_storage(); let active = storage.active_validators_accessor(); @@ -650,7 +745,7 @@ pub fn is_validator_active(sdk: &mut SDK, input: &[u8]) -> Resul /// Public handler `0xa310624f` (`getValidatorStatus`). /// -/// Returns the validator's owner, status, stake, slash, jail, claim, and commission data. +/// Returns the validator's owner, status, stake, claim, and commission data. pub fn get_validator_status(sdk: &mut SDK, input: &[u8]) -> Result<(), ExitCode> { ensure_non_payable(sdk)?; let validator = address_arg(input)?; @@ -664,9 +759,7 @@ pub fn get_validator_status(sdk: &mut SDK, input: &[u8]) -> Resu record.owner_accessor().get_checked(sdk)?, record.status_accessor().get_checked(sdk)?, math::expand_balance(snapshot.total_delegated_accessor().get_checked(sdk)?), - snapshot.slashes_count_accessor().get_checked(sdk)?, changed_at, - record.jailed_before_accessor().get_checked(sdk)?, record.claimed_at_accessor().get_checked(sdk)?, snapshot.commission_rate_accessor().get_checked(sdk)?, ); @@ -752,7 +845,13 @@ pub fn activate_validator(sdk: &mut SDK, input: &[u8]) -> Result .active_validators_accessor() .push_checked(sdk, validator)?; ensure_rostered(sdk, validator)?; - set_selection_visible(sdk, validator, true, current_epoch(sdk)?)?; + // The visibility stamp is the whole selection filter, so re-stamping here + // would silently cancel a running exclusion. The validator still becomes + // Active; it stays unselectable until the exclusion's own release path + // re-stamps it. + if liveness::readmit_at_epoch_of(sdk, validator)? == 0 { + set_selection_visible(sdk, validator, true, current_epoch(sdk)?)?; + } touch_snapshot_at_or_before(sdk, validator, activation_epoch)?; emit_modified(sdk, validator) } @@ -1842,22 +1941,27 @@ pub fn get_epoch_rewards(sdk: &mut SDK, input: &[u8]) -> Result< write_abi(sdk, &total) } -fn fault_tolerance(n: usize) -> usize { - if n == 0 { - 0 - } else { - (n - 1) / 3 - } -} - -fn settle_one( - sdk: &mut SDK, - epoch: u64, - liveness: Address, - reserve: Address, -) -> Result<(), ExitCode> { +fn settle_one(sdk: &mut SDK, epoch: u64, reserve: Address) -> Result<(), ExitCode> { let storage = staking_storage(); let consensus = consensus_storage(); + // Belongs here rather than at the close's call site: `settle_up_to` walks the + // cursor contiguously, so an epoch whose close never ran — a stalled recorder, + // a pre-activation prefix — would otherwise be paid a full pot for no blocks + // as soon as a later epoch settles. + if production_liveness_storage() + .blocks_in_epoch_accessor() + .entry(epoch) + .get_checked(sdk)? + == 0 + { + events::StipendSkipped { epoch }.emit(sdk)?; + events::EpochBlendRewardsCommitted { + epoch, + blend_amount: U256::ZERO, + } + .emit(sdk)?; + return Ok(()); + } let committee = consensus.epoch_committees_accessor().entry(epoch); let len = committee.len_checked(sdk)?; let desired = chain_config_storage() @@ -1877,75 +1981,53 @@ fn settle_one( let mut shares = vec![U256::ZERO; len as usize]; if !pot.is_zero() && len != 0 { - let (_, certs) = - call_decode::<_, _, (u32, u32)>(sdk, liveness, SIG_PARTICIPATION, &(epoch, 0u32))?; - if certs != 0 { - let floor = chain_config_storage() - .participation_floor_bps_accessor() - .get_checked(sdk)?; - let floor = if floor == 0 { - DEFAULT_PARTICIPATION_FLOOR_BPS - } else { - floor - }; - let mut passed = vec![false; len as usize]; - let mut below = 0usize; - for index in 0..len { - let (seen, _) = call_decode::<_, _, (u32, u32)>( - sdk, - liveness, - SIG_PARTICIPATION, - &(epoch, index as u32), - )?; - if U256::from(seen) * U256::from(10_000) < U256::from(certs) * U256::from(floor) { - below += 1; - } else { - passed[index as usize] = true; - } + // Weights are the ones frozen at commit time, not a live stake walk: the + // committee was ranked and the leader drawn from this same vector, so a + // stake change after the commit must not move anyone's share. + let frozen = consensus.leader_stakes_accessor().entry(epoch); + // Paying a prefix would hand the whole pot to the members that happen to + // have weights and then advance the cursor past the epoch, so a mismatch + // must stop the settlement rather than narrow it. `getEpochCommitteeWithStakes` + // rejects the same condition. + if frozen.len_checked(sdk)? != len { + return revert_with( + sdk, + ERR_LEADER_STAKES_LENGTH_MISMATCH, + &(epoch, U256::from(len), U256::from(frozen.len_checked(sdk)?)), + ); + } + let mut weights = vec![U256::ZERO; len as usize]; + let mut total_weight = U256::ZERO; + for index in 0..len { + let validator = committee.at(index).get_checked(sdk)?; + if consensus + .tombstoned_accessor() + .entry(validator) + .get_checked(sdk)? + { + continue; } - // During a partition, keep stake-weighted rewards available to - // participating and non-participating committee members alike. - let partition = below > fault_tolerance(len as usize); - let mut stakes = vec![U256::ZERO; len as usize]; - let mut total_stake = U256::ZERO; - for index in 0..len { - let validator = committee.at(index).get_checked(sdk)?; - if (!partition && !passed[index as usize]) - || consensus - .tombstoned_accessor() - .entry(validator) - .get_checked(sdk)? - || storage - .validators_accessor() - .entry(validator) - .status_accessor() - .get_checked(sdk)? - == STATUS_NOT_FOUND - { - continue; - } - let stake = validator_total_at(sdk, validator, epoch)?; - if stake.is_zero() { + let weight = U256::from(frozen.at(index).get_checked(sdk)?); + if weight.is_zero() { + continue; + } + weights[index as usize] = weight; + total_weight = total_weight + .checked_add(weight) + .ok_or(ExitCode::IntegerOverflow)?; + } + if !total_weight.is_zero() { + for (index, weight) in weights.into_iter().enumerate() { + if weight.is_zero() { continue; } - stakes[index as usize] = stake; - total_stake = total_stake - .checked_add(stake) + let share = + pot.checked_mul(weight).ok_or(ExitCode::IntegerOverflow)? / total_weight; + shares[index] = share; + assigned = assigned + .checked_add(share) .ok_or(ExitCode::IntegerOverflow)?; } - if !total_stake.is_zero() { - for (index, stake) in stakes.into_iter().enumerate() { - if stake.is_zero() { - continue; - } - let share = - pot.checked_mul(stake).ok_or(ExitCode::IntegerOverflow)? / total_stake; - shares[index] = share; - assigned = assigned - .checked_add(share) - .ok_or(ExitCode::IntegerOverflow)?; - } - } } } @@ -1996,26 +2078,27 @@ fn settle_one( .emit(sdk) } -/// Public handler `0xa631344a` (`settleEpochStipend`). +/// Settles every unsettled epoch up to `up_to`, contiguously from the cursor. /// -/// Settles and distributes the finalized epoch stipend. -pub fn settle_epoch_stipend(sdk: &mut SDK, input: &[u8]) -> Result<(), ExitCode> { - ensure_non_payable(sdk)?; - ensure_mutable(sdk)?; - ensure_initialized(sdk)?; - if sdk.context().contract_caller() != SYSTEM_CALLER { - return revert(sdk, ERR_ONLY_SYSTEM_CALL); - } - let requested = decode::(input)?.value; +/// The cursor advances past every epoch this returns `Ok` for, so a replay +/// re-draws nothing. Note that an epoch skipped by a guard inside `settle_one` +/// is forfeited, not deferred — only a revert leaves it to be retried. +pub(crate) fn settle_up_to(sdk: &mut SDK, up_to: u64) -> Result<(), ExitCode> { let storage = staking_storage(); - let config = chain_config_storage(); - let liveness = config.liveness_slashing_accessor().get_checked(sdk)?; - let reserve = config.blend_reserve_accessor().get_checked(sdk)?; - let finalized_p1 = call_decode::<_, _, u64>(sdk, liveness, SIG_LAST_FINALIZED_EPOCH_P1, &())?; - if finalized_p1 == 0 { + let reserve = chain_config_storage() + .blend_reserve_accessor() + .get_checked(sdk)?; + // A committee may be committed up to two epochs ahead, so `epoch_committees` + // and `leader_stakes` exist for epochs that have not started. Paying one + // draws a full pot for an epoch with no production and advances the cursor + // past it irrecoverably. The finished-epoch bound replaces the finality gate + // the liveness contract used to provide; a per-epoch "has data" belt takes + // over once block production is recorded on chain. + let current = current_epoch(sdk)?; + if current == 0 { return Ok(()); } - let up_to = core::cmp::min(requested, finalized_p1 - 1); + let up_to = core::cmp::min(up_to, current - 1); let first = storage.last_rewarded_epoch_p1_accessor().get_checked(sdk)?; if first != 0 && up_to.checked_add(1).ok_or(ExitCode::IntegerOverflow)? <= first { return Ok(()); @@ -2023,7 +2106,7 @@ pub fn settle_epoch_stipend(sdk: &mut SDK, input: &[u8]) -> Resu let mut epoch = first; let mut settled = 0; while epoch <= up_to && settled < MAX_SETTLE_CATCHUP { - settle_one(sdk, epoch, liveness, reserve)?; + settle_one(sdk, epoch, reserve)?; storage .last_rewarded_epoch_p1_accessor() .set_checked(sdk, epoch.checked_add(1).ok_or(ExitCode::IntegerOverflow)?)?; @@ -2032,3 +2115,35 @@ pub fn settle_epoch_stipend(sdk: &mut SDK, input: &[u8]) -> Resu } Ok(()) } + +/// Public handler `0xa631344a` (`settleEpochStipend`). +/// +/// Settles and distributes the epoch stipend. +pub fn settle_epoch_stipend(sdk: &mut SDK, input: &[u8]) -> Result<(), ExitCode> { + ensure_non_payable(sdk)?; + ensure_mutable(sdk)?; + ensure_initialized(sdk)?; + if sdk.context().contract_caller() != SYSTEM_CALLER { + return revert(sdk, ERR_ONLY_SYSTEM_CALL); + } + settle_up_to(sdk, decode::(input)?.value) +} + +/// Public handler `0x92d321ab` (`settleEpochStipendFrom`). +/// +/// The stipend leg of the epoch close. Reachable only from this contract's own +/// fuel-capped self-call, which is what gives the leg a journal checkpoint of +/// its own: a failure here discards this frame and nothing the close already +/// committed above it. +pub fn settle_epoch_stipend_from( + sdk: &mut SDK, + input: &[u8], +) -> Result<(), ExitCode> { + ensure_non_payable(sdk)?; + ensure_mutable(sdk)?; + ensure_initialized(sdk)?; + if sdk.context().contract_caller() != sdk.context().contract_address() { + return revert(sdk, ERR_ONLY_SELF_CALL); + } + settle_up_to(sdk, decode::(input)?.value) +} diff --git a/contracts/staking/src/storage.rs b/contracts/staking/src/storage.rs index 04725f1bf..912bec2fa 100644 --- a/contracts/staking/src/storage.rs +++ b/contracts/staking/src/storage.rs @@ -2,7 +2,7 @@ use crate::consts::{ CHAIN_CONFIG_STORAGE_SLOT, CONSENSUS_STORAGE_SLOT, INITIALIZER_STORAGE_SLOT, - STAKING_STORAGE_SLOT, + PRODUCTION_LIVENESS_STORAGE_SLOT, STAKING_STORAGE_SLOT, }; use fluentbase_sdk::{ derive::Storage, @@ -30,23 +30,35 @@ pub struct ChainConfigStorage { dpos_activation_block: StorageU64, min_validator_stake_amount: StorageU256, min_staking_amount: StorageU256, - felony_threshold: StorageU32, - validator_jail_epoch_length: StorageU32, slash_reporter_reward_bps: StorageU32, slash_fund_address: StorageAddress, - participation_floor_bps: StorageU32, - participation_jail_disabled: StorageBool, blend_stipend_per_epoch: StorageU256, bls_verifier: StorageAddress, evidence_decoder: StorageAddress, min_undelegate_blocks: StorageU256, + /// Unread. The jail tier that consumed it is gone and the production-liveness + /// tier that replaced it runs inside this contract, so the principal is the + /// contract itself. Still required non-zero at initialization, and the + /// initializer's selector is pinned, so removing it is a deliberate ABI break. liveness_slashing: StorageAddress, blend_reserve: StorageAddress, + /// Committee size cap history, ascending by `from_epoch`. + /// + /// Appended, never inserted: declaration order is the storage layout. + cap_checkpoints: StorageVec, + min_verdict_due_blocks: StorageU32, + exclusion_backoff_cap: StorageU32, + /// Kill switch for the production-liveness tier, seeded `true` at init. + /// + /// Raw, never sentinel-on-zero: a fresh slot reads `false`, which is the + /// opposite of the intended default, so the seed is the only thing keeping + /// the tier off on a new chain. + production_liveness_disabled: StorageBool, } /// Fixed-size validator metadata. /// -/// Epoch-varying stake, commission, and slash counters live exclusively in +/// Epoch-varying stake and commission live exclusively in /// `ValidatorSnapshotStorage`, avoiding duplicate sources of truth. #[derive(Storage)] pub struct ValidatorStorage { @@ -54,7 +66,6 @@ pub struct ValidatorStorage { owner: StorageAddress, status: StorageU8, changed_at: StorageU64, - jailed_before: StorageU64, claimed_at: StorageU64, /// First initialized snapshot epoch plus one (`0` means no snapshot). /// @@ -73,12 +84,23 @@ pub struct ValidatorStorage { pub struct ValidatorSnapshotStorage { /// Stake in `BALANCE_COMPACT_PRECISION` units. total_delegated: StorageUint112, - slashes_count: StorageU32, commission_rate: StorageU16, /// Per-epoch BLEND reward in token base units; never copied forward. total_blend_rewards: StorageUint96, } +/// Committee size cap in force from `from_epoch` onward. +/// +/// The epoch-frozen selection view stands on three epoch-addressed legs: +/// visibility, stake, and this cap. Reading the cap live was the missing leg — +/// a governance change would retroactively rewrite the committee of an epoch +/// that had already been committed. +#[derive(Storage)] +pub struct CapCheckpointStorage { + from_epoch: StorageU64, + value: StorageU32, +} + /// Effective delegation balance beginning at `epoch`. #[derive(Storage)] pub struct DelegationOpStorage { @@ -139,7 +161,7 @@ pub struct EquivocationCommitmentStorage { committed_at: StorageU64, } -/// ERC-7201 namespaced consensus, liveness, and equivocation state. +/// ERC-7201 namespaced consensus, committee, and equivocation state. #[derive(Storage)] pub struct ConsensusStorage { consensus_keys: StorageMap, @@ -148,8 +170,6 @@ pub struct ConsensusStorage { dkg_qual: StorageMap, last_committed_epoch_p1: StorageU64, pruned_up_to_p1: StorageU64, - jailed_validators: StorageVec, - jailed_scan_cursor: StorageU64, tombstoned: StorageMap, equivocation_commitments: StorageMap, /// Validator owning a canonical compressed BLS key, indexed by its keccak256 hash. @@ -158,6 +178,13 @@ pub struct ConsensusStorage { bls_pubkey_owner: StorageMap, /// Exclusive equivocation-evidence deadline snapshotted for each committee. committee_liability_end_epochs: StorageMap, + /// Leader weights stamped at commit time, positional with `epoch_committees`. + /// + /// Stored in `BALANCE_COMPACT_PRECISION` units. 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 — so an unfrozen weight is + /// a per-node leader split, not an accounting rounding error. + leader_stakes: StorageMap>, } /// Single ERC-7201 namespaced storage root for staking. @@ -182,6 +209,43 @@ pub struct StakingStorage { validator_snapshot_epochs: StorageMap>, } +/// One validator's block-production record. +/// +/// Field order is split by write frequency: the two counters the per-block +/// credit touches share one slot, so recording a block costs a single store no +/// matter how the epoch-close fields below them grow. +#[derive(Storage)] +pub struct ProductionValidatorStorage { + total_produced: StorageU64, + /// Epoch of the most recent credited block plus one (`0` means never). + last_produced_epoch_p1: StorageU64, + /// Epoch of the most recent failing verdict plus one (`0` means never). + last_failed_epoch_p1: StorageU64, + /// Epoch at whose close the exclusion is released (`0` means not excluded). + readmit_at_epoch: StorageU64, + /// Exclusion episodes, not verdicts. Never decays. + kick_count: StorageU32, +} + +/// ERC-7201 namespaced block-production accounting. +#[derive(Storage)] +pub struct ProductionLivenessStorage { + /// Highest recorded block: both the idempotency belt and the epoch cursor. + /// + /// There is deliberately no second epoch scalar. The epoch is a pure + /// function of the height, and two cursors obliged to agree can disagree. + last_processed_block: StorageU64, + /// Blocks credited per (epoch, committee index). + /// + /// Keyed by index rather than by address because index `i` names a + /// different validator in every epoch. + produced: StorageMap>, + blocks_in_epoch: StorageMap, + /// Live exclusions; the length is the concurrent count. + pending_exclusions: StorageVec, + validators: StorageMap, +} + pub fn initializer_storage() -> InitializerStorage { InitializerStorage::new(INITIALIZER_STORAGE_SLOT, 0) } @@ -197,3 +261,7 @@ pub fn consensus_storage() -> ConsensusStorage { pub fn staking_storage() -> StakingStorage { StakingStorage::new(STAKING_STORAGE_SLOT, 0) } + +pub fn production_liveness_storage() -> ProductionLivenessStorage { + ProductionLivenessStorage::new(PRODUCTION_LIVENESS_STORAGE_SLOT, 0) +} diff --git a/contracts/staking/src/tests.rs b/contracts/staking/src/tests.rs index 927fce7e3..fd72864c0 100644 --- a/contracts/staking/src/tests.rs +++ b/contracts/staking/src/tests.rs @@ -1,15 +1,17 @@ use super::*; use crate::{ - consts::{STATUS_ACTIVE, STATUS_JAIL, STATUS_PENDING}, + consts::{STATUS_ACTIVE, STATUS_PENDING}, storage::{ - chain_config_storage, consensus_storage, initializer_storage, staking_storage, - ConsensusKeysStorage, DelegationOpStorage, UndelegationOpStorage, ValidatorSnapshotStorage, + chain_config_storage, consensus_storage, initializer_storage, production_liveness_storage, + staking_storage, CapCheckpointStorage, ConsensusKeysStorage, DelegationOpStorage, + ProductionValidatorStorage, UndelegationOpStorage, ValidatorSnapshotStorage, + ValidatorStorage, }, types::{ AddValidatorCommand, AddressCommand, AddressU16Command, BoolCommand, ConsensusKeys, EpochSignerCommand, EquivocationCommand, InitializeCommand, RegisterValidatorCommand, - TwoAddressesCommand, U256Command, U32Command, U64Command, ValidatorBlockCommand, - ValidatorDelegatorCommand, ValidatorEpochCommand, + RecordProductionCommand, TwoAddressesCommand, U256Command, U32Command, U64Command, + ValidatorBlockCommand, ValidatorDelegatorCommand, ValidatorEpochCommand, }, }; use fluentbase_sdk::{ @@ -52,7 +54,7 @@ fn encode_empty_call(selector: u32) -> Vec { #[test] fn compact_storage_matches_solidity_struct_layouts() { assert_eq!(ValidatorSnapshotStorage::SLOTS, 1); - assert_eq!(::BYTES, 32); + assert_eq!(::BYTES, 28); assert_eq!(DelegationOpStorage::SLOTS, 1); assert_eq!(::BYTES, 22); assert_eq!(UndelegationOpStorage::SLOTS, 1); @@ -63,9 +65,37 @@ fn compact_storage_matches_solidity_struct_layouts() { let snapshot = ValidatorSnapshotStorage::new(slot, 0); assert_eq!(snapshot.total_delegated_accessor().slot(), slot); assert_eq!(snapshot.total_delegated_accessor().offset(), 18); - assert_eq!(snapshot.slashes_count_accessor().offset(), 14); - assert_eq!(snapshot.commission_rate_accessor().offset(), 12); - assert_eq!(snapshot.total_blend_rewards_accessor().offset(), 0); + assert_eq!(snapshot.commission_rate_accessor().offset(), 16); + assert_eq!(snapshot.total_blend_rewards_accessor().offset(), 4); + + // Removing a field from the middle of a packed word relocates everything + // below it, so the surviving offsets are pinned rather than assumed. + assert_eq!(ValidatorStorage::SLOTS, 2); + let validator = ValidatorStorage::new(slot, 0); + assert_eq!(validator.owner_accessor().offset(), 12); + assert_eq!(validator.status_accessor().offset(), 11); + assert_eq!(validator.changed_at_accessor().offset(), 3); + assert_eq!(validator.claimed_at_accessor().slot(), slot + U256::from(1)); + + assert_eq!(CapCheckpointStorage::SLOTS, 1); + assert_eq!(::BYTES, 12); + + // The per-block credit writes `total_produced` and `last_produced_epoch_p1` + // together; both must stay inside the first slot or every recorded block + // costs a second store. + assert_eq!(ProductionValidatorStorage::SLOTS, 2); + let production = ProductionValidatorStorage::new(slot, 0); + assert_eq!(production.total_produced_accessor().slot(), slot); + assert_eq!(production.total_produced_accessor().offset(), 24); + assert_eq!(production.last_produced_epoch_p1_accessor().slot(), slot); + assert_eq!(production.last_produced_epoch_p1_accessor().offset(), 16); + assert_eq!(production.last_failed_epoch_p1_accessor().offset(), 8); + assert_eq!(production.readmit_at_epoch_accessor().offset(), 0); + assert_eq!( + production.kick_count_accessor().slot(), + slot + U256::from(1) + ); + assert_eq!(production.kick_count_accessor().offset(), 28); } #[test] @@ -74,17 +104,28 @@ fn contract_storage_uses_separate_erc7201_namespaces() { let chain_config_slot = chain_config_storage().staking_token_accessor().slot(); let consensus_slot = consensus_storage().consensus_keys_accessor().slot(); let staking_slot = staking_storage().validators_accessor().slot(); + let liveness_slot = production_liveness_storage() + .last_processed_block_accessor() + .slot(); assert_eq!(initializer_slot, INITIALIZER_STORAGE_SLOT); assert_eq!(chain_config_slot, CHAIN_CONFIG_STORAGE_SLOT); assert_eq!(consensus_slot, CONSENSUS_STORAGE_SLOT); assert_eq!(staking_slot, STAKING_STORAGE_SLOT); - assert_ne!(initializer_slot, chain_config_slot); - assert_ne!(initializer_slot, consensus_slot); - assert_ne!(initializer_slot, staking_slot); - assert_ne!(chain_config_slot, consensus_slot); - assert_ne!(chain_config_slot, staking_slot); - assert_ne!(consensus_slot, staking_slot); + assert_eq!(liveness_slot, PRODUCTION_LIVENESS_STORAGE_SLOT); + let roots = [ + initializer_slot, + chain_config_slot, + consensus_slot, + staking_slot, + liveness_slot, + ]; + for (index, root) in roots.iter().enumerate() { + assert!( + !roots[index + 1..].contains(root), + "storage root {index} aliases a later namespace" + ); + } } struct Harness { @@ -197,8 +238,6 @@ impl Harness { staking_token: Address::with_last_byte(0xf0), active_validators_length: DEFAULT_ACTIVE_VALIDATORS_LENGTH as u32, epoch_block_interval: DEFAULT_EPOCH_BLOCK_INTERVAL as u32, - felony_threshold: DEFAULT_FELONY_THRESHOLD, - validator_jail_epoch_length: DEFAULT_VALIDATOR_JAIL_EPOCH_LENGTH, undelegate_period: DEFAULT_UNDELEGATE_PERIOD as u32, min_validator_stake_amount: DEFAULT_MIN_VALIDATOR_STAKE, min_staking_amount: DEFAULT_MIN_STAKING_AMOUNT, @@ -258,6 +297,28 @@ fn store_test_consensus_keys( .unwrap(); } +/// Writes an epoch committee and its frozen leader weights, the pair +/// `commitEpochCommittee` appends together and the stipend reads back. +fn commit_test_committee(sdk: &mut TestingContextImpl, epoch: u64, members: &[(Address, U256)]) { + let consensus = consensus_storage(); + let committee = consensus.epoch_committees_accessor().entry(epoch); + let stakes = consensus.leader_stakes_accessor().entry(epoch); + for (validator, stake) in members { + committee.push_checked(sdk, *validator).unwrap(); + stakes + .push_checked(sdk, crate::math::compact_balance(*stake).unwrap()) + .unwrap(); + } +} + +fn record_test_production(sdk: &mut TestingContextImpl, epoch: u64, blocks: u32) { + production_liveness_storage() + .blocks_in_epoch_accessor() + .entry(epoch) + .set_checked(sdk, blocks) + .unwrap(); +} + enum MockDisbursement { Amount(U256), EmptyReturn, @@ -265,9 +326,7 @@ enum MockDisbursement { } struct StipendCallState { - liveness: Address, reserve: Address, - finalized_epoch_p1: u64, reserve_balances: VecDeque, disbursements: VecDeque, reserve_balance_reads: usize, @@ -317,7 +376,6 @@ fn stipend_test_sdk( ) -> (Harness, Rc>, Address) { let owner = Address::with_last_byte(0xa0); let validator = Address::with_last_byte(0x01); - let liveness = Address::with_last_byte(0xb0); let reserve = Address::with_last_byte(0xc0); let mut harness = Harness::new(1_000); assert_eq!( @@ -330,26 +388,23 @@ fn stipend_test_sdk( .blend_stipend_per_epoch_accessor() .set_checked(&mut harness.sdk, U256::from(100)) .unwrap(); - config - .liveness_slashing_accessor() - .set_checked(&mut harness.sdk, liveness) - .unwrap(); config .blend_reserve_accessor() .set_checked(&mut harness.sdk, reserve) .unwrap(); - consensus_storage() - .epoch_committees_accessor() - .entry(0) - .push_checked(&mut harness.sdk, validator) - .unwrap(); + commit_test_committee( + &mut harness.sdk, + 0, + &[(validator, DEFAULT_MIN_VALIDATOR_STAKE)], + ); + // Epoch 0 is only settleable once it is over and only if it recorded blocks. + record_test_production(&mut harness.sdk, 0, DEFAULT_EPOCH_BLOCK_INTERVAL as u32); + harness.set_block_number(1_000 + DEFAULT_EPOCH_BLOCK_INTERVAL); harness.set_caller(SYSTEM_CALLER); harness.sdk.take_logs(); let calls = Rc::new(RefCell::new(StipendCallState { - liveness, reserve, - finalized_epoch_p1: 1, reserve_balances: reserve_balances.into(), disbursements: disbursements.into(), reserve_balance_reads: 0, @@ -365,22 +420,11 @@ fn stipend_test_sdk( let selector = u32::from_be_bytes(input[..SIG_LEN_BYTES].try_into().unwrap()); let mut calls = call_state.borrow_mut(); match (address, selector) { - (address, SIG_LAST_FINALIZED_EPOCH_P1) if address == calls.liveness => { - SyscallResult::new( - encode_mock_return(&calls.finalized_epoch_p1), - 0, - 0, - ExitCode::Ok, - ) - } (address, SIG_RESERVE_BALANCE) if address == calls.reserve => { calls.reserve_balance_reads += 1; let balance = calls.reserve_balances.pop_front().unwrap_or(U256::ZERO); SyscallResult::new(encode_mock_return(&balance), 0, 0, ExitCode::Ok) } - (address, SIG_PARTICIPATION) if address == calls.liveness => { - SyscallResult::new(encode_mock_return(&(1u32, 1u32)), 0, 0, ExitCode::Ok) - } (address, SIG_RESERVE_DISBURSE) if address == calls.reserve => { let params = &input[SIG_LEN_BYTES..]; let (recipient, assigned) = @@ -873,15 +917,13 @@ fn parameterized_custom_errors_use_solidity_abi() { #[test] fn derived_selectors_match_independent_hex_pins() { for (actual, pinned) in [ - (SIG_INITIALIZE, 0x4b4b21a5), + (SIG_INITIALIZE, 0xd86555fe), (SIG_CURRENT_EPOCH, 0x76671808), (SIG_NEXT_EPOCH, 0xaea0e78b), (SIG_GET_STAKING_TOKEN, 0x9f9106d1), - (SIG_DEFAULT_PARTICIPATION_FLOOR_BPS, 0x2c1d88e8), (SIG_DEFAULT_SLASH_REPORTER_BPS, 0x6cc69027), (SIG_MAX_ACTIVE_VALIDATORS, 0x5d887462), (SIG_MAX_BLEND_STIPEND_PER_EPOCH, 0x2bc2fec4), - (SIG_MAX_PARTICIPATION_FLOOR_BPS, 0x9dbdf12b), (SIG_MAX_SLASH_REPORTER_BPS, 0x0a3a6183), (SIG_GET_VALIDATOR_DELEGATION, 0xd951e186), (SIG_GET_VALIDATOR_DELEGATED_STAKE_AT, 0xe8810ea7), @@ -899,14 +941,11 @@ fn derived_selectors_match_independent_hex_pins() { (SIG_CHANGE_VALIDATOR_COMMISSION_RATE, 0x14f8649f), (SIG_CHANGE_VALIDATOR_OWNER, 0x0052c9e1), (SIG_SET_ACTIVE_VALIDATORS_LENGTH, 0xc227a412), + (SIG_GET_ACTIVE_VALIDATORS_LENGTH_AT, 0xd9b083ba), (SIG_SET_EPOCH_BLOCK_INTERVAL, 0xaf70fa2c), (SIG_SET_DPOS_ACTIVATION_BLOCK, 0xf517ca6a), - (SIG_SET_FELONY_THRESHOLD, 0xfcd6cb3e), - (SIG_SET_VALIDATOR_JAIL_EPOCH_LENGTH, 0xc8652bd5), (SIG_SET_SLASH_REPORTER_REWARD_BPS, 0x58702003), (SIG_SET_SLASH_FUND_ADDRESS, 0xa79e7263), - (SIG_SET_PARTICIPATION_FLOOR_BPS, 0xd0a01007), - (SIG_SET_PARTICIPATION_JAIL_DISABLED, 0x8664f2e7), (SIG_SET_BLEND_STIPEND_PER_EPOCH, 0x2c91b879), (SIG_SET_UNDELEGATE_PERIOD, 0x41d8a080), (SIG_SET_MIN_VALIDATOR_STAKE_AMOUNT, 0xe1a2e863), @@ -928,19 +967,103 @@ fn derived_selectors_match_independent_hex_pins() { (SIG_GET_VALIDATORS_WITH_KEYS_AT, 0x7cfba9f3), (SIG_COMMIT_EPOCH_COMMITTEE, 0x87401d8a), (SIG_GET_EPOCH_COMMITTEE_WITH_STAKES, 0xa4d160c1), - (SIG_RELEASE_VALIDATOR_FROM_JAIL, 0x73a3dda6), - (SIG_SLASH, 0xc96be4cb), (SIG_COMMIT_EQUIVOCATION_REPORT, 0x32890bc0), (SIG_COMPUTE_EQUIVOCATION_REPORT_COMMITMENT, 0xc289d76e), (SIG_GET_EQUIVOCATION_REPORT_COMMITMENT, 0xa3aae5dd), (SIG_SLASH_EQUIVOCATION_NOTARIZE, 0x2bc5fb10), (SIG_SLASH_EQUIVOCATION_FINALIZE, 0xb034c58b), (SIG_SLASH_EQUIVOCATION_NULLIFY_FINALIZE, 0x337e1437), + (SIG_DEFAULT_MIN_VERDICT_DUE_BLOCKS, 0x6fd3afb7), + (SIG_DEFAULT_EXCLUSION_BACKOFF_CAP, 0xd4c30c1a), + (SIG_MAX_MIN_VERDICT_DUE_BLOCKS, 0x9b9a11ba), + (SIG_GET_MIN_VERDICT_DUE_BLOCKS, 0xee3ad0e7), + (SIG_SET_MIN_VERDICT_DUE_BLOCKS, 0x4fae9dea), + (SIG_GET_EXCLUSION_BACKOFF_CAP, 0x6bed0322), + (SIG_SET_EXCLUSION_BACKOFF_CAP, 0x3b543e1c), + (SIG_GET_PRODUCTION_LIVENESS_DISABLED, 0x9a4c46bb), + (SIG_SET_PRODUCTION_LIVENESS_DISABLED, 0x8fc07556), + (SIG_GET_PRODUCTION_STATS, 0x8e948ac1), + (SIG_BLOCKS_IN_EPOCH, 0xf06be669), + (SIG_PRODUCED_AT, 0x91c7d453), + (SIG_PENDING_EXCLUSIONS, 0xaef690f9), + (SIG_READMIT_AT_EPOCH, 0x32066046), + (SIG_LAST_PROCESSED_BLOCK, 0x33de61d2), + (SIG_RECORD_PRODUCTION, 0x8244a2c2), + (SIG_SETTLE_EPOCH_STIPEND_FROM, 0x92d321ab), + (ERR_MIN_VERDICT_DUE_BLOCKS_TOO_HIGH, 0xb1776ed0), + (ERR_ONLY_SELF_CALL, 0xff54bf4b), ] { assert_eq!(actual, pinned); } } +#[test] +fn production_liveness_event_signatures_match_the_solidity_abi() { + // Event field types are mapped by type name and an unrecognised path + // degrades silently to `tuple`, so the signatures are pinned as strings + // rather than compared against the same derivation that produced them. + assert_eq!( + events::MinVerdictDueBlocksChanged::SIGNATURE, + "MinVerdictDueBlocksChanged(uint32,uint32)" + ); + assert_eq!( + events::ExclusionBackoffCapChanged::SIGNATURE, + "ExclusionBackoffCapChanged(uint32,uint32)" + ); + assert_eq!( + events::ProductionLivenessDisabledChanged::SIGNATURE, + "ProductionLivenessDisabledChanged(bool,bool)" + ); + assert_eq!( + events::ProductionExclusionApplied::SIGNATURE, + "ProductionExclusionApplied(address,uint64)" + ); + assert_eq!( + events::ProductionExclusionReleased::SIGNATURE, + "ProductionExclusionReleased(address,uint64)" + ); + assert_eq!( + events::PartialEpoch::SIGNATURE, + "PartialEpoch(uint64,uint32,uint32)" + ); + assert_eq!( + events::ProductionVerdictFailed::SIGNATURE, + "ProductionVerdictFailed(uint64,address,uint32,uint256)" + ); + assert_eq!( + events::CorrelatedFailureEpoch::SIGNATURE, + "CorrelatedFailureEpoch(uint64,uint256,uint256)" + ); + assert_eq!( + events::StipendLegSkipped::SIGNATURE, + "StipendLegSkipped(uint64)" + ); + assert_eq!( + events::PartialEpoch::SELECTOR, + hex!("0e3a2b176af3f126559b647eec7cf85052c5cf6239a5fb6869c57d8416225690") + ); + assert_eq!( + events::ProductionVerdictFailed::SELECTOR, + hex!("4d49874ac1e640f94f7ab435dd2305f79014052c7c659aac7f36742d043f8bd8") + ); + assert_eq!( + events::CorrelatedFailureEpoch::SELECTOR, + hex!("3a7c10dc4c9367950614ebeed14db659b710db26c303e92aaf4ac4cdd5b10925") + ); + assert_eq!( + events::StipendLegSkipped::SELECTOR, + hex!("d4266dfa609215f824cf7ef1953a79620625b0e0595260ffd393280da7285dbd") + ); + assert_eq!( + events::ProductionExclusionApplied::SELECTOR, + hex!("b8336509c4e8c35e4348c3c1666d3687b7ba430c61e7d929d7f50010984da426") + ); + assert_eq!( + events::ProductionExclusionReleased::SELECTOR, + hex!("1ce26a6c35478b6335c4d63746f3728de9394747163281c4383bf3ac95137644") + ); +} + #[test] fn initializes_registry_and_preserves_solidity_read_abi() { let governance = Address::with_last_byte(0xa0); @@ -973,7 +1096,7 @@ fn initializes_registry_and_preserves_solidity_read_abi() { SIG_GET_VALIDATOR_STATUS, &AddressCommand { value: validator_a }, )); - let status: (Address, u8, U256, u32, u64, u64, u64, u16) = decode_output(&output); + let status: (Address, u8, U256, u64, u64, u16) = decode_output(&output); assert_eq!( status, ( @@ -982,8 +1105,6 @@ fn initializes_registry_and_preserves_solidity_read_abi() { U256::from(10) * DEFAULT_MIN_VALIDATOR_STAKE, 0, 0, - 0, - 0, 500, ) ); @@ -1153,10 +1274,6 @@ fn staking_is_a_genesis_rwasm_contract_not_a_system_precompile() { fn embedded_chain_config_exposes_solidity_public_constants() { let mut harness = Harness::new(0); for (selector, expected) in [ - ( - SIG_DEFAULT_PARTICIPATION_FLOOR_BPS, - DEFAULT_PARTICIPATION_FLOOR_BPS, - ), ( SIG_DEFAULT_SLASH_REPORTER_BPS, DEFAULT_SLASH_REPORTER_REWARD_BPS, @@ -1165,7 +1282,6 @@ fn embedded_chain_config_exposes_solidity_public_constants() { SIG_MAX_ACTIVE_VALIDATORS, MAX_ACTIVE_VALIDATORS_LENGTH as u32, ), - (SIG_MAX_PARTICIPATION_FLOOR_BPS, MAX_PARTICIPATION_FLOOR_BPS), (SIG_MAX_SLASH_REPORTER_BPS, MAX_SLASH_REPORTER_REWARD_BPS), ] { let (_, output) = harness.call(encode_empty_call(selector)); @@ -1186,8 +1302,6 @@ fn stores_chain_configuration_in_its_own_namespace() { command.staking_token = staking_token; command.active_validators_length = 50; command.epoch_block_interval = 100; - command.felony_threshold = 150; - command.validator_jail_epoch_length = 7; command.undelegate_period = 7; command.min_validator_stake_amount = BALANCE_COMPACT_PRECISION; command.min_staking_amount = BALANCE_COMPACT_PRECISION; @@ -1265,7 +1379,7 @@ fn governance_updates_embedded_chain_configuration() { assert_eq!( harness .call(encode_call( - SIG_SET_FELONY_THRESHOLD, + SIG_SET_SLASH_REPORTER_REWARD_BPS, &U32Command { value: 3 }, )) .0, @@ -1299,10 +1413,7 @@ fn governance_updates_embedded_chain_configuration() { ); } for (selector, value) in [ - (SIG_SET_FELONY_THRESHOLD, 3), - (SIG_SET_VALIDATOR_JAIL_EPOCH_LENGTH, 4), (SIG_SET_SLASH_REPORTER_REWARD_BPS, 2_500), - (SIG_SET_PARTICIPATION_FLOOR_BPS, 1_000), (SIG_SET_ACTIVE_VALIDATORS_LENGTH, 31), (SIG_SET_UNDELEGATE_PERIOD, 9), ] { @@ -1325,15 +1436,6 @@ fn governance_updates_embedded_chain_configuration() { ExitCode::Ok ); } - assert_eq!( - harness - .call(encode_call( - SIG_SET_PARTICIPATION_JAIL_DISABLED, - &BoolCommand { value: true }, - )) - .0, - ExitCode::Ok - ); assert_eq!( harness .call(encode_call( @@ -1347,10 +1449,7 @@ fn governance_updates_embedded_chain_configuration() { ); for (selector, expected) in [ - (SIG_GET_FELONY_THRESHOLD, 3), - (SIG_GET_VALIDATOR_JAIL_EPOCH_LENGTH, 4), (SIG_GET_SLASH_REPORTER_REWARD_BPS, 2_500), - (SIG_GET_PARTICIPATION_FLOOR_BPS, 1_000), (SIG_GET_ACTIVE_VALIDATORS_LENGTH, 31), (SIG_GET_UNDELEGATE_PERIOD, 9), ] { @@ -1369,8 +1468,6 @@ fn governance_updates_embedded_chain_configuration() { assert_eq!(exit, ExitCode::Ok); assert_eq!(decode_output::
(&output), expected); } - let (_, output) = harness.call(encode_empty_call(SIG_GET_PARTICIPATION_JAIL_DISABLED)); - assert!(decode_output::(&output)); let (_, output) = harness.call(encode_empty_call(SIG_GET_BLEND_STIPEND_PER_EPOCH)); assert_eq!(decode_output::(&output), U256::from(42)); @@ -1402,8 +1499,6 @@ fn initialize_events_report_defaults_as_previous_values() { command.staking_token = Address::with_last_byte(0xb0); command.active_validators_length = 31; command.epoch_block_interval = 200; - command.felony_threshold = 3; - command.validator_jail_epoch_length = 4; command.undelegate_period = 9; command.min_validator_stake_amount = DEFAULT_MIN_VALIDATOR_STAKE * U256::from(2); command.min_staking_amount = DEFAULT_MIN_STAKING_AMOUNT * U256::from(2); @@ -1430,14 +1525,6 @@ fn initialize_events_report_defaults_as_previous_values() { u32_event(events::EpochBlockIntervalChanged::SELECTOR), (DEFAULT_EPOCH_BLOCK_INTERVAL as u32, 200) ); - assert_eq!( - u32_event(events::FelonyThresholdChanged::SELECTOR), - (DEFAULT_FELONY_THRESHOLD, 3) - ); - assert_eq!( - u32_event(events::ValidatorJailEpochLengthChanged::SELECTOR), - (DEFAULT_VALIDATOR_JAIL_EPOCH_LENGTH, 4) - ); assert_eq!( u32_event(events::UndelegatePeriodChanged::SELECTOR), (DEFAULT_UNDELEGATE_PERIOD as u32, 9) @@ -1921,8 +2008,6 @@ fn delegation_and_undelegation_follow_epoch_snapshots() { command.staking_token = token; command.active_validators_length = 21; command.epoch_block_interval = 200; - command.felony_threshold = 150; - command.validator_jail_epoch_length = 7; command.undelegate_period = 7; command.min_validator_stake_amount = one_token; command.min_staking_amount = one_token; @@ -1987,827 +2072,896 @@ fn delegation_and_undelegation_follow_epoch_snapshots() { assert_eq!(decode_output::(&output), one_token * U256::from(11)); } +// Leader weight is drawn from the SELECTION epoch (target - 2), the same vintage +// that ranked membership. Stamping `target` instead yields a contract that is +// internally consistent and still wrong: nothing reverts, the leader is just +// weighted by an epoch the committee was not chosen under. #[test] -fn future_delegation_and_noop_commission_do_not_bypass_warmup() { - let contract_owner = Address::with_last_byte(0xa0); - let validator_a = Address::with_last_byte(0x01); - let validator_b = Address::with_last_byte(0x02); +fn leader_weights_are_frozen_at_the_selection_epoch_vintage() { + let owner = Address::with_last_byte(0xa0); + let validator = Address::with_last_byte(0x01); let delegator = Address::with_last_byte(0xb0); - let initial_a = DEFAULT_MIN_VALIDATOR_STAKE * U256::from(2); - let initial_b = DEFAULT_MIN_VALIDATOR_STAKE * U256::from(3); - let delegated = DEFAULT_MIN_STAKING_AMOUNT * U256::from(2); + let initial = DEFAULT_MIN_VALIDATOR_STAKE * U256::from(2); + let added = DEFAULT_MIN_STAKING_AMOUNT * U256::from(5); let mut harness = Harness::new(1_000); assert_eq!( - harness.initialize( - contract_owner, - vec![validator_a, validator_b], - vec![initial_a, initial_b], - 0, - ), - ExitCode::Ok - ); - chain_config_storage() - .active_validators_length_accessor() - .set_checked(&mut harness.sdk, 1) - .unwrap(); - - staking::delegate_to(&mut harness.sdk, delegator, validator_a, delegated, false).unwrap(); - harness.set_caller(validator_a); - assert_eq!( - harness - .call(encode_call( - SIG_CHANGE_VALIDATOR_COMMISSION_RATE, - &AddressU16Command { - validator: validator_a, - value: 0, - }, - )) - .0, + harness.initialize(owner, vec![validator], vec![initial], 0), ExitCode::Ok ); + // Effective at epoch 2, so epoch 0 and epoch 2 hold different stakes. + staking::delegate_to(&mut harness.sdk, delegator, validator, added, false).unwrap(); assert_eq!( - staking::validator_total_at(&harness.sdk, validator_a, 1).unwrap(), - initial_a, - "the E+2 delegation must not be copied into the E+1 commission snapshot" - ); - assert_eq!( - staking::validator_total_at(&harness.sdk, validator_a, 2).unwrap(), - initial_a + delegated + staking::validator_total_at(&harness.sdk, validator, 0).unwrap(), + initial ); assert_eq!( - staking::selected_validators_at(&harness.sdk, 1).unwrap(), - vec![validator_b] + staking::validator_total_at(&harness.sdk, validator, 2).unwrap(), + initial + added ); + + harness.set_caller(SYSTEM_CALLER); + for _ in 0..3 { + assert_eq!( + harness + .call(encode_args_call( + SIG_COMMIT_EPOCH_COMMITTEE, + &(vec![validator],), + )) + .0, + ExitCode::Ok + ); + } + + let (_, output) = harness.call(encode_call( + SIG_GET_EPOCH_COMMITTEE_WITH_STAKES, + &U64Command { value: 2 }, + )); + let (_, _, stakes): (Vec
, Vec, Vec) = decode_returns(&output); assert_eq!( - staking::selected_validators_at(&harness.sdk, 2).unwrap(), - vec![validator_a] + stakes, + vec![initial], + "epoch 2 was selected from epoch 0 and must carry epoch 0's weight" ); - let reward = DEFAULT_MIN_STAKING_AMOUNT; - staking_storage() - .validator_snapshots_accessor() - .entry(validator_a) - .entry(1) - .total_blend_rewards_accessor() - .set_checked( - &mut harness.sdk, - math::narrow_reward(reward).expect("reward fits uint96"), - ) - .unwrap(); - staking_storage() - .last_rewarded_epoch_p1_accessor() - .set_checked(&mut harness.sdk, 2) - .unwrap(); - harness.set_block_number(1_400); + staking::delegate_to(&mut harness.sdk, delegator, validator, added, false).unwrap(); let (_, output) = harness.call(encode_call( - SIG_GET_DELEGATOR_FEE, - &ValidatorDelegatorCommand { - validator: validator_a, - delegator: validator_a, - }, + SIG_GET_EPOCH_COMMITTEE_WITH_STAKES, + &U64Command { value: 2 }, )); + let (_, _, stakes): (Vec
, Vec, Vec) = decode_returns(&output); assert_eq!( - decode_output::(&output), - reward, - "future stake must not dilute rewards before its warm-up completes" + stakes, + vec![initial], + "a committed epoch's weights do not move when stake changes afterwards" ); } #[test] -fn commission_change_carries_forward_without_copying_future_stake_backward() { - let contract_owner = Address::with_last_byte(0xa0); +fn pruning_drops_leader_weights_with_their_committee() { + let owner = Address::with_last_byte(0xa0); let validator = Address::with_last_byte(0x01); - let delegator = Address::with_last_byte(0xb0); - let initial = DEFAULT_MIN_VALIDATOR_STAKE * U256::from(2); - let delegated = DEFAULT_MIN_STAKING_AMOUNT; let mut harness = Harness::new(1_000); assert_eq!( - harness.initialize(contract_owner, vec![validator], vec![initial], 500), + harness.initialize(owner, vec![validator], vec![DEFAULT_MIN_VALIDATOR_STAKE], 0), ExitCode::Ok ); - staking::delegate_to(&mut harness.sdk, delegator, validator, delegated, false).unwrap(); - harness.set_caller(validator); + harness.set_caller(SYSTEM_CALLER); assert_eq!( harness - .call(encode_call( - SIG_CHANGE_VALIDATOR_COMMISSION_RATE, - &AddressU16Command { - validator, - value: 1_000, - }, + .call(encode_args_call( + SIG_COMMIT_EPOCH_COMMITTEE, + &(vec![validator],), )) .0, ExitCode::Ok ); - let snapshots = staking_storage() - .validator_snapshots_accessor() - .entry(validator); + harness.set_block_number(1_000 + 60 * DEFAULT_EPOCH_BLOCK_INTERVAL); assert_eq!( - math::expand_balance( - snapshots - .entry(1) - .total_delegated_accessor() - .get_checked(&harness.sdk) - .unwrap() - ), - initial + harness + .call(encode_args_call( + SIG_COMMIT_EPOCH_COMMITTEE, + &(vec![validator],), + )) + .0, + ExitCode::Ok ); - assert_eq!( - math::expand_balance( - snapshots - .entry(2) - .total_delegated_accessor() - .get_checked(&harness.sdk) - .unwrap() - ), - initial + delegated + + let (exit, output) = harness.call(encode_call( + SIG_GET_EPOCH_COMMITTEE_WITH_STAKES, + &U64Command { value: 0 }, + )); + assert_eq!(exit, ExitCode::Ok); + let (validators, _, stakes): (Vec
, Vec, Vec) = + decode_returns(&output); + assert!( + validators.is_empty() && stakes.is_empty(), + "a pruned epoch answers empty, not a length mismatch" ); - for epoch in [1, 2] { - assert_eq!( - snapshots - .entry(epoch) - .commission_rate_accessor() - .get_checked(&harness.sdk) - .unwrap(), - 1_000 - ); - } } +// The committee cap was the last input of the epoch-frozen selection view still +// read live: raising it used to retroactively enlarge the committee of an epoch +// that had already been committed, which desynchronises the DKG index space +// from the committed committee. #[test] -fn sparse_snapshot_lookup_uses_sorted_materialized_epochs() { +fn raising_the_cap_leaves_already_started_epochs_untouched() { let owner = Address::with_last_byte(0xa0); - let validator = Address::with_last_byte(0x01); - let stake = DEFAULT_MIN_VALIDATOR_STAKE; - let mut harness = Harness::new(0); + let big = Address::with_last_byte(0x01); + let small = Address::with_last_byte(0x02); + let mut harness = Harness::new(1_000); + let mut command = harness.initialize_command( + owner, + vec![big, small], + vec![ + DEFAULT_MIN_VALIDATOR_STAKE * U256::from(5), + DEFAULT_MIN_VALIDATOR_STAKE * U256::from(2), + ], + 500, + ); + command.active_validators_length = 1; + assert_eq!(harness.initialize_with(command), ExitCode::Ok); assert_eq!( - harness.initialize(owner, vec![validator], vec![stake], 0), - ExitCode::Ok + staking::selected_validators_at(&harness.sdk, 0).unwrap(), + vec![big] ); - let future = - staking::touch_snapshot_at_or_before(&mut harness.sdk, validator, 1_000_000).unwrap(); - future - .total_delegated_accessor() - .set_checked( - &mut harness.sdk, - math::compact_balance(stake * U256::from(2)).unwrap(), - ) - .unwrap(); - staking::touch_snapshot_at_or_before(&mut harness.sdk, validator, 500).unwrap(); - - let epochs = staking_storage() - .validator_snapshot_epochs_accessor() - .entry(validator); - assert_eq!(epochs.len_checked(&harness.sdk).unwrap(), 3); - assert_eq!(epochs.at(0).get_checked(&harness.sdk).unwrap(), 0); - assert_eq!(epochs.at(1).get_checked(&harness.sdk).unwrap(), 500); - assert_eq!(epochs.at(2).get_checked(&harness.sdk).unwrap(), 1_000_000); + harness.set_caller(GENESIS_GOVERNANCE); + harness.sdk.take_logs(); assert_eq!( - staking::validator_total_at(&harness.sdk, validator, 999_999).unwrap(), - stake + harness + .call(encode_call( + SIG_SET_ACTIVE_VALIDATORS_LENGTH, + &U32Command { value: 2 }, + )) + .0, + ExitCode::Ok ); + let logs = harness.sdk.take_logs(); + let (data, _) = logs + .iter() + .find(|(_, topics)| { + topics.first() == Some(&B256::new(events::ActiveValidatorsLengthChanged::SELECTOR)) + }) + .expect("cap change event"); assert_eq!( - staking::validator_total_at(&harness.sdk, validator, 1_000_000).unwrap(), - stake * U256::from(2) + decode_output::<(u32, u32, u64)>(data), + (1, 2, 1), + "the event must announce the epoch the new cap first governs, not the current one" ); -} -#[test] -fn undelegation_rejects_a_later_pending_delegation_checkpoint() { - let owner = Address::with_last_byte(0xa0); - let delegator = Address::with_last_byte(0xb0); - let validator = Address::with_last_byte(0x01); - let delegated = DEFAULT_MIN_STAKING_AMOUNT * U256::from(2); - let undelegated = DEFAULT_MIN_STAKING_AMOUNT; - let mut harness = Harness::new(1_000); - harness.set_caller(owner); assert_eq!( - harness.initialize( - owner, - vec![validator], - vec![DEFAULT_MIN_VALIDATOR_STAKE], - 500, - ), - ExitCode::Ok - ); - - staking::delegate_to(&mut harness.sdk, delegator, validator, delegated, false).unwrap(); - assert_direct_revert( - staking::undelegate_from(&mut harness.sdk, delegator, validator, undelegated), - &harness.sdk, - ERR_PENDING_DELEGATION, + staking::selected_validators_at(&harness.sdk, 0).unwrap(), + vec![big], + "epoch 0 has already started and keeps the cap it was selected under" ); assert_eq!( - staking::validator_total_at(&harness.sdk, validator, 1).unwrap(), - DEFAULT_MIN_VALIDATOR_STAKE + staking::selected_validators_at(&harness.sdk, 1).unwrap(), + vec![big, small] ); + + let (_, output) = harness.call(encode_call( + SIG_GET_ACTIVE_VALIDATORS_LENGTH_AT, + &U64Command { value: 0 }, + )); + assert_eq!(decode_output::(&output), 1); + let (_, output) = harness.call(encode_call( + SIG_GET_ACTIVE_VALIDATORS_LENGTH_AT, + &U64Command { value: 1 }, + )); + assert_eq!(decode_output::(&output), 2); + let (_, output) = harness.call(encode_empty_call(SIG_GET_ACTIVE_VALIDATORS_LENGTH)); assert_eq!( - staking::validator_total_at(&harness.sdk, validator, 2).unwrap(), - DEFAULT_MIN_VALIDATOR_STAKE + delegated + decode_output::(&output), + 2, + "the scalar reports the latest scheduled value immediately" ); +} - harness.set_block_number(1_200); - staking::undelegate_from(&mut harness.sdk, delegator, validator, undelegated).unwrap(); - assert_eq!( - staking::validator_total_at(&harness.sdk, validator, 1).unwrap(), - DEFAULT_MIN_VALIDATOR_STAKE +// The other half of the key filter: keys that exist but activate later must be +// treated exactly like absent keys on both legs. +#[test] +fn keys_activating_after_the_selection_epoch_are_filtered_after_the_cut() { + let owner = Address::with_last_byte(0xa0); + let future = Address::with_last_byte(0x01); + let keyed = Address::with_last_byte(0x02); + let spare = Address::with_last_byte(0x03); + let mut harness = Harness::new(1_000); + let mut command = harness.initialize_command( + owner, + vec![future, keyed, spare], + vec![ + DEFAULT_MIN_VALIDATOR_STAKE * U256::from(9), + DEFAULT_MIN_VALIDATOR_STAKE * U256::from(3), + DEFAULT_MIN_VALIDATOR_STAKE * U256::from(2), + ], + 500, ); + command.active_validators_length = 2; + assert_eq!(harness.initialize_with(command), ExitCode::Ok); + + consensus_storage() + .consensus_keys_accessor() + .entry(future) + .activation_epoch_accessor() + .set_checked(&mut harness.sdk, 7) + .unwrap(); + + let (_, output) = harness.call(encode_call( + SIG_GET_VALIDATORS_WITH_KEYS_AT, + &U64Command { value: 0 }, + )); + let (view, view_keys): (Vec
, Vec) = decode_returns(&output); assert_eq!( - staking::validator_total_at(&harness.sdk, validator, 2).unwrap(), - DEFAULT_MIN_VALIDATOR_STAKE + delegated - undelegated + view, + vec![future, keyed], + "the not-yet-activated validator still occupies its top-k slot" ); - let latest = staking_storage() - .validator_delegations_accessor() - .entry(validator) - .entry(delegator) - .delegate_queue_accessor() - .at(0); - assert_eq!( - math::expand_balance(latest.amount_accessor().get_checked(&harness.sdk).unwrap()), - delegated - undelegated + assert!(view_keys[0].bls_pubkey.is_empty()); + + harness.set_caller(SYSTEM_CALLER); + assert_revert_selector( + harness.call(encode_args_call( + SIG_COMMIT_EPOCH_COMMITTEE, + &(vec![keyed, spare],), + )), + ERR_COMMITTEE_LENGTH_MISMATCH, ); assert_eq!( - latest.epoch_accessor().get_checked(&harness.sdk).unwrap(), - 2 + harness + .call(encode_args_call(SIG_COMMIT_EPOCH_COMMITTEE, &(vec![keyed],))) + .0, + ExitCode::Ok ); } #[test] -fn reward_views_split_blend_between_owner_and_delegators() { +fn committee_stake_read_rejects_a_committee_without_matching_frozen_weights() { let owner = Address::with_last_byte(0xa0); - let delegator = Address::with_last_byte(0xb0); let validator = Address::with_last_byte(0x01); - let ten_tokens = DEFAULT_MIN_STAKING_AMOUNT * U256::from(10); let mut harness = Harness::new(1_000); - harness.set_caller(owner); assert_eq!( - harness.initialize(owner, vec![validator], vec![ten_tokens], 1_000), + harness.initialize(owner, vec![validator], vec![DEFAULT_MIN_VALIDATOR_STAKE], 0), ExitCode::Ok ); - staking::delegate_to(&mut harness.sdk, delegator, validator, ten_tokens, false).unwrap(); - let snapshot = staking_storage() - .validator_snapshots_accessor() - .entry(validator) - .entry(2); - snapshot - .total_blend_rewards_accessor() - .set_checked( - &mut harness.sdk, - math::narrow_reward(ten_tokens).expect("reward fits uint96"), - ) - .unwrap(); - staking_storage() - .last_rewarded_epoch_p1_accessor() - .set_checked(&mut harness.sdk, 3) + + // A committee whose weights were never stamped: the reader must refuse it + // rather than fall back to a live, height-dependent walk. + consensus_storage() + .epoch_committees_accessor() + .entry(4) + .push_checked(&mut harness.sdk, validator) .unwrap(); - harness.set_block_number(1_600); - let (_, output) = harness.call(encode_call( - SIG_GET_VALIDATOR_FEE, - &AddressCommand { value: validator }, - )); - assert_eq!(decode_output::(&output), DEFAULT_MIN_STAKING_AMOUNT); + assert_revert_selector( + harness.call(encode_call( + SIG_GET_EPOCH_COMMITTEE_WITH_STAKES, + &U64Command { value: 4 }, + )), + ERR_LEADER_STAKES_LENGTH_MISMATCH, + ); +} - let (_, output) = harness.call(encode_call( - SIG_GET_DELEGATOR_FEE, - &ValidatorDelegatorCommand { - validator, - delegator, - }, - )); +#[test] +fn repeated_cap_changes_in_one_epoch_collapse_into_a_single_checkpoint() { + let owner = Address::with_last_byte(0xa0); + let validator = Address::with_last_byte(0x01); + let mut harness = Harness::new(1_000); assert_eq!( - decode_output::(&output), - U256::from(9) * DEFAULT_MIN_STAKING_AMOUNT / U256::from(2) + harness.initialize(owner, vec![validator], vec![DEFAULT_MIN_VALIDATOR_STAKE], 0), + ExitCode::Ok ); - let (_, output) = harness.call(encode_call( - SIG_CALC_AVAILABLE_FOR_REDELEGATE_AMOUNT, - &ValidatorDelegatorCommand { - validator, - delegator, - }, - )); + harness.set_caller(GENESIS_GOVERNANCE); + for value in [2u32, 3, 4] { + assert_eq!( + harness + .call(encode_call( + SIG_SET_ACTIVE_VALIDATORS_LENGTH, + &U32Command { value }, + )) + .0, + ExitCode::Ok + ); + } + assert_eq!( - decode_output::<(U256, U256)>(&output), - ( - U256::from(9) * DEFAULT_MIN_STAKING_AMOUNT / U256::from(2), - U256::ZERO - ) + chain_config_storage() + .cap_checkpoints_accessor() + .len_checked(&harness.sdk) + .unwrap(), + 2, + "the genesis checkpoint plus one pending entry, not one entry per call" ); + let (_, output) = harness.call(encode_call( + SIG_GET_ACTIVE_VALIDATORS_LENGTH_AT, + &U64Command { value: 1 }, + )); + assert_eq!(decode_output::(&output), 4); } #[test] -fn committee_commit_is_system_gated_and_returns_epoch_stakes() { - let owner = Address::with_last_byte(0xa0); +fn future_delegation_and_noop_commission_do_not_bypass_warmup() { + let contract_owner = Address::with_last_byte(0xa0); let validator_a = Address::with_last_byte(0x01); let validator_b = Address::with_last_byte(0x02); - let stake_a = U256::from(10) * DEFAULT_MIN_VALIDATOR_STAKE; - let stake_b = U256::from(20) * DEFAULT_MIN_VALIDATOR_STAKE; + let delegator = Address::with_last_byte(0xb0); + let initial_a = DEFAULT_MIN_VALIDATOR_STAKE * U256::from(2); + let initial_b = DEFAULT_MIN_VALIDATOR_STAKE * U256::from(3); + let delegated = DEFAULT_MIN_STAKING_AMOUNT * U256::from(2); let mut harness = Harness::new(1_000); - harness.set_caller(owner); assert_eq!( harness.initialize( - owner, + contract_owner, vec![validator_a, validator_b], - vec![stake_a, stake_b], - 500, + vec![initial_a, initial_b], + 0, ), ExitCode::Ok ); - - let committee = (vec![validator_a, validator_b],); + // Through governance, not a raw storage poke: committee selection reads the + // cap checkpoint, and only the setter schedules one. + harness.set_caller(GENESIS_GOVERNANCE); assert_eq!( harness - .call(encode_args_call(SIG_COMMIT_EPOCH_COMMITTEE, &committee)) + .call(encode_call( + SIG_SET_ACTIVE_VALIDATORS_LENGTH, + &U32Command { value: 1 }, + )) .0, - ExitCode::Panic + ExitCode::Ok ); - harness.set_caller(SYSTEM_CALLER); + + staking::delegate_to(&mut harness.sdk, delegator, validator_a, delegated, false).unwrap(); + harness.set_caller(validator_a); assert_eq!( harness - .call(encode_args_call(SIG_COMMIT_EPOCH_COMMITTEE, &committee)) + .call(encode_call( + SIG_CHANGE_VALIDATOR_COMMISSION_RATE, + &AddressU16Command { + validator: validator_a, + value: 0, + }, + )) .0, ExitCode::Ok ); - let (_, output) = harness.call(encode_call( - SIG_GET_EPOCH_COMMITTEE, - &U64Command { value: 0 }, - )); assert_eq!( - decode_output::>(&output), - vec![validator_a, validator_b] + staking::validator_total_at(&harness.sdk, validator_a, 1).unwrap(), + initial_a, + "the E+2 delegation must not be copied into the E+1 commission snapshot" ); - let (_, output) = harness.call(encode_call( - SIG_RESOLVE_SIGNER, - &EpochSignerCommand { - epoch: 0, - signer_idx: 1, - }, - )); - assert_eq!(decode_output::
(&output), validator_b); - let (_, output) = harness.call(encode_empty_call(SIG_NEXT_EPOCH_TO_COMMIT)); - assert_eq!(decode_output::(&output), 1); - let (_, output) = harness.call(encode_call( - SIG_GET_EPOCH_COMMITTEE_WITH_STAKES, - &U64Command { value: 0 }, - )); - let (validators, keys, stakes): (Vec
, Vec, Vec) = - decode_returns(&output); - assert_eq!(validators, vec![validator_a, validator_b]); assert_eq!( - keys.iter() - .map(|value| value.bls_pubkey[0]) - .collect::>(), - vec![0x33, 0x34] + staking::validator_total_at(&harness.sdk, validator_a, 2).unwrap(), + initial_a + delegated ); - assert_eq!(stakes, vec![stake_a, stake_b]); - - let logs = harness.sdk.take_logs(); - let (_, topics) = logs - .iter() - .find(|(_, topics)| { - topics.first() == Some(&B256::new(events::EpochCommitteeCommitted::SELECTOR)) - }) - .expect("committee event"); - assert_eq!(topics.len(), 2); - let (data, _) = logs - .iter() - .find(|(_, topics)| { - topics.first() == Some(&B256::new(events::EpochCommitteeCommitted::SELECTOR)) - }) - .expect("committee event"); - let (event_committee,): (Vec
,) = decode_returns(data); - assert_eq!(event_committee, vec![validator_a, validator_b]); -} - -#[test] -fn committee_filters_keyless_validators_before_top_k_ranking() { - let owner = Address::with_last_byte(0xa0); - let keyless = Address::with_last_byte(0x01); - let keyed_a = Address::with_last_byte(0x02); - let keyed_b = Address::with_last_byte(0x03); - let validators = vec![keyless, keyed_a, keyed_b]; - let mut harness = Harness::new(1_000); - let mut command = harness.initialize_command( - owner, - validators.clone(), - vec![ - DEFAULT_MIN_VALIDATOR_STAKE * U256::from(100), - DEFAULT_MIN_VALIDATOR_STAKE * U256::from(3), - DEFAULT_MIN_VALIDATOR_STAKE * U256::from(2), - ], - 500, + assert_eq!( + staking::selected_validators_at(&harness.sdk, 1).unwrap(), + vec![validator_b] ); - command.active_validators_length = 2; - assert_eq!(harness.initialize_with(command), ExitCode::Ok); - - let keyless_keys = consensus_storage().consensus_keys_accessor().entry(keyless); - keyless_keys - .peer_pubkey_accessor() - .set_checked(&mut harness.sdk, B256::ZERO) - .unwrap(); - - harness.set_caller(SYSTEM_CALLER); assert_eq!( - harness - .call(encode_args_call( - SIG_COMMIT_EPOCH_COMMITTEE, - &(vec![keyed_a, keyed_b],), - )) - .0, - ExitCode::Ok + staking::selected_validators_at(&harness.sdk, 2).unwrap(), + vec![validator_a] ); + + let reward = DEFAULT_MIN_STAKING_AMOUNT; + staking_storage() + .validator_snapshots_accessor() + .entry(validator_a) + .entry(1) + .total_blend_rewards_accessor() + .set_checked( + &mut harness.sdk, + math::narrow_reward(reward).expect("reward fits uint96"), + ) + .unwrap(); + staking_storage() + .last_rewarded_epoch_p1_accessor() + .set_checked(&mut harness.sdk, 2) + .unwrap(); + harness.set_block_number(1_400); let (_, output) = harness.call(encode_call( - SIG_GET_EPOCH_COMMITTEE, - &U64Command { value: 0 }, + SIG_GET_DELEGATOR_FEE, + &ValidatorDelegatorCommand { + validator: validator_a, + delegator: validator_a, + }, )); assert_eq!( - decode_output::>(&output), - vec![keyed_a, keyed_b] + decode_output::(&output), + reward, + "future stake must not dilute rewards before its warm-up completes" ); } #[test] -fn fully_keyless_committee_reverts_without_advancing_commit_pointer() { - let owner = Address::with_last_byte(0xa0); - let validators = vec![Address::with_last_byte(0x01), Address::with_last_byte(0x02)]; +fn commission_change_carries_forward_without_copying_future_stake_backward() { + let contract_owner = Address::with_last_byte(0xa0); + let validator = Address::with_last_byte(0x01); + let delegator = Address::with_last_byte(0xb0); + let initial = DEFAULT_MIN_VALIDATOR_STAKE * U256::from(2); + let delegated = DEFAULT_MIN_STAKING_AMOUNT; let mut harness = Harness::new(1_000); assert_eq!( - harness.initialize( - owner, - validators.clone(), - vec![DEFAULT_MIN_VALIDATOR_STAKE; validators.len()], - 500, - ), + harness.initialize(contract_owner, vec![validator], vec![initial], 500), ExitCode::Ok ); - for validator in &validators { - consensus_storage() - .consensus_keys_accessor() - .entry(*validator) - .peer_pubkey_accessor() - .set_checked(&mut harness.sdk, B256::ZERO) - .unwrap(); - } - harness.set_caller(SYSTEM_CALLER); - assert_revert_selector( - harness.call(encode_args_call( - SIG_COMMIT_EPOCH_COMMITTEE, - &(Vec::
::new(),), - )), - ERR_COMMITTEE_TOO_SMALL, - ); - - let (_, output) = harness.call(encode_empty_call(SIG_NEXT_EPOCH_TO_COMMIT)); - assert_eq!(decode_output::(&output), 0); - let (_, output) = harness.call(encode_call( - SIG_GET_EPOCH_COMMITTEE_LENGTH, - &U64Command { value: 0 }, - )); - assert_eq!(decode_output::(&output), U256::ZERO); + staking::delegate_to(&mut harness.sdk, delegator, validator, delegated, false).unwrap(); - for (index, validator) in validators.iter().enumerate() { - let key_byte = (index + 1) as u8; - store_test_consensus_keys( - &mut harness.sdk, - *validator, - key_byte, - B256::with_last_byte(key_byte), - 0, - ); - } + harness.set_caller(validator); assert_eq!( harness - .call(encode_args_call( - SIG_COMMIT_EPOCH_COMMITTEE, - &(validators.clone(),), + .call(encode_call( + SIG_CHANGE_VALIDATOR_COMMISSION_RATE, + &AddressU16Command { + validator, + value: 1_000, + }, )) .0, ExitCode::Ok ); - let (_, output) = harness.call(encode_empty_call(SIG_NEXT_EPOCH_TO_COMMIT)); - assert_eq!(decode_output::(&output), 1); -} -#[test] -fn equal_stake_top_k_preserves_solidity_roster_order() { - let owner = Address::with_last_byte(0xa0); - let first = Address::with_last_byte(0xf0); - let second = Address::with_last_byte(0x01); - let mut harness = Harness::new(1_000); - harness.set_caller(owner); + let snapshots = staking_storage() + .validator_snapshots_accessor() + .entry(validator); assert_eq!( - harness.initialize( - owner, - vec![first, second], - vec![DEFAULT_MIN_VALIDATOR_STAKE, DEFAULT_MIN_VALIDATOR_STAKE], - 0, + math::expand_balance( + snapshots + .entry(1) + .total_delegated_accessor() + .get_checked(&harness.sdk) + .unwrap() ), - ExitCode::Ok + initial ); - chain_config_storage() - .active_validators_length_accessor() - .set_checked(&mut harness.sdk, 1) - .unwrap(); - - let (_, output) = harness.call(encode_empty_call(SIG_GET_VALIDATORS)); - assert_eq!(decode_output::>(&output), vec![first]); + assert_eq!( + math::expand_balance( + snapshots + .entry(2) + .total_delegated_accessor() + .get_checked(&harness.sdk) + .unwrap() + ), + initial + delegated + ); + for epoch in [1, 2] { + assert_eq!( + snapshots + .entry(epoch) + .commission_rate_accessor() + .get_checked(&harness.sdk) + .unwrap(), + 1_000 + ); + } } #[test] -fn selection_filters_active_validator_below_current_minimum() { +fn sparse_snapshot_lookup_uses_sorted_materialized_epochs() { let owner = Address::with_last_byte(0xa0); let validator = Address::with_last_byte(0x01); - let mut harness = Harness::new(1_000); + let stake = DEFAULT_MIN_VALIDATOR_STAKE; + let mut harness = Harness::new(0); assert_eq!( - harness.initialize(owner, vec![validator], vec![DEFAULT_MIN_VALIDATOR_STAKE], 0,), + harness.initialize(owner, vec![validator], vec![stake], 0), ExitCode::Ok ); - chain_config_storage() - .min_validator_stake_amount_accessor() + + let future = + staking::touch_snapshot_at_or_before(&mut harness.sdk, validator, 1_000_000).unwrap(); + future + .total_delegated_accessor() .set_checked( &mut harness.sdk, - DEFAULT_MIN_VALIDATOR_STAKE * U256::from(2), + math::compact_balance(stake * U256::from(2)).unwrap(), ) .unwrap(); + staking::touch_snapshot_at_or_before(&mut harness.sdk, validator, 500).unwrap(); + let epochs = staking_storage() + .validator_snapshot_epochs_accessor() + .entry(validator); + assert_eq!(epochs.len_checked(&harness.sdk).unwrap(), 3); + assert_eq!(epochs.at(0).get_checked(&harness.sdk).unwrap(), 0); + assert_eq!(epochs.at(1).get_checked(&harness.sdk).unwrap(), 500); + assert_eq!(epochs.at(2).get_checked(&harness.sdk).unwrap(), 1_000_000); assert_eq!( - staking_storage() - .validators_accessor() - .entry(validator) - .status_accessor() - .get_checked(&harness.sdk) - .unwrap(), - STATUS_ACTIVE, - "selection must defend independently from lifecycle status" + staking::validator_total_at(&harness.sdk, validator, 999_999).unwrap(), + stake + ); + assert_eq!( + staking::validator_total_at(&harness.sdk, validator, 1_000_000).unwrap(), + stake * U256::from(2) ); - let (_, output) = harness.call(encode_empty_call(SIG_GET_VALIDATORS)); - assert!(decode_output::>(&output).is_empty()); - assert!(staking::selected_validators_at(&harness.sdk, 0) - .unwrap() - .is_empty()); } #[test] -fn committee_pruning_keeps_dkg_history() { +fn undelegation_rejects_a_later_pending_delegation_checkpoint() { let owner = Address::with_last_byte(0xa0); + let delegator = Address::with_last_byte(0xb0); let validator = Address::with_last_byte(0x01); - let activation_block = 1_000; - let mut harness = Harness::new(activation_block); + let delegated = DEFAULT_MIN_STAKING_AMOUNT * U256::from(2); + let undelegated = DEFAULT_MIN_STAKING_AMOUNT; + let mut harness = Harness::new(1_000); harness.set_caller(owner); assert_eq!( - harness.initialize(owner, vec![validator], vec![DEFAULT_MIN_VALIDATOR_STAKE], 0,), + harness.initialize( + owner, + vec![validator], + vec![DEFAULT_MIN_VALIDATOR_STAKE], + 500, + ), ExitCode::Ok ); - harness.set_block_number( - activation_block - + DEFAULT_EPOCH_BLOCK_INTERVAL - * (DEFAULT_UNDELEGATE_PERIOD + EPOCH_COMMITTEE_RETENTION_MARGIN + 2), - ); - consensus_storage() - .dkg_qual_accessor() - .entry(1) - .set_checked(&mut harness.sdk, true) - .unwrap(); - harness.set_caller(SYSTEM_CALLER); + staking::delegate_to(&mut harness.sdk, delegator, validator, delegated, false).unwrap(); + assert_direct_revert( + staking::undelegate_from(&mut harness.sdk, delegator, validator, undelegated), + &harness.sdk, + ERR_PENDING_DELEGATION, + ); assert_eq!( - harness - .call(encode_args_call( - SIG_COMMIT_EPOCH_COMMITTEE, - &(vec![validator],), - )) - .0, - ExitCode::Ok + staking::validator_total_at(&harness.sdk, validator, 1).unwrap(), + DEFAULT_MIN_VALIDATOR_STAKE ); - assert!(consensus_storage() - .dkg_qual_accessor() - .entry(1) - .get_checked(&harness.sdk) - .unwrap()); -} - -#[test] -fn liveness_slash_jails_and_readmits_without_breaking_quorum() { - let owner = Address::with_last_byte(0xa0); - let liveness = Address::with_last_byte(0xb0); - let reserve = Address::with_last_byte(0xb1); - let validators = (1..=4) - .map(Address::with_last_byte) - .collect::>(); - let mut harness = Harness::new(1_000); - harness.set_caller(owner); - let mut command = harness.initialize_command( - owner, - validators.clone(), - vec![DEFAULT_MIN_VALIDATOR_STAKE; validators.len()], - 500, + assert_eq!( + staking::validator_total_at(&harness.sdk, validator, 2).unwrap(), + DEFAULT_MIN_VALIDATOR_STAKE + delegated ); - command.liveness_slashing = liveness; - command.blend_reserve = reserve; - assert_eq!(harness.initialize_with(command), ExitCode::Ok); - harness.set_caller(liveness); + harness.set_block_number(1_200); + staking::undelegate_from(&mut harness.sdk, delegator, validator, undelegated).unwrap(); assert_eq!( - harness - .call(encode_call( - SIG_SLASH, - &AddressCommand { - value: validators[0], - }, - )) - .0, - ExitCode::Ok + staking::validator_total_at(&harness.sdk, validator, 1).unwrap(), + DEFAULT_MIN_VALIDATOR_STAKE ); assert_eq!( - staking_storage() - .validators_accessor() - .entry(validators[0]) - .status_accessor() - .get_checked(&harness.sdk) - .unwrap(), - crate::consts::STATUS_JAIL + staking::validator_total_at(&harness.sdk, validator, 2).unwrap(), + DEFAULT_MIN_VALIDATOR_STAKE + delegated - undelegated ); - - harness.set_block_number(1_200); + let latest = staking_storage() + .validator_delegations_accessor() + .entry(validator) + .entry(delegator) + .delegate_queue_accessor() + .at(0); assert_eq!( - harness - .call(encode_call( - SIG_READMIT_EXPIRED_JAILS, - &U64Command { value: 1 }, - )) - .0, - ExitCode::Ok + math::expand_balance(latest.amount_accessor().get_checked(&harness.sdk).unwrap()), + delegated - undelegated ); assert_eq!( - staking_storage() - .validators_accessor() - .entry(validators[0]) - .status_accessor() - .get_checked(&harness.sdk) - .unwrap(), - STATUS_ACTIVE + latest.epoch_accessor().get_checked(&harness.sdk).unwrap(), + 2 ); } #[test] -fn liveness_slashing_preserves_fixed_committed_committee_quorum() { +fn reward_views_split_blend_between_owner_and_delegators() { let owner = Address::with_last_byte(0xa0); - let liveness = Address::with_last_byte(0xb0); - let validators = (1..=8) - .map(Address::with_last_byte) - .collect::>(); - let committee = validators[..7].to_vec(); + let delegator = Address::with_last_byte(0xb0); + let validator = Address::with_last_byte(0x01); + let ten_tokens = DEFAULT_MIN_STAKING_AMOUNT * U256::from(10); let mut harness = Harness::new(1_000); - let mut command = harness.initialize_command( - owner, - validators.clone(), - vec![DEFAULT_MIN_VALIDATOR_STAKE; validators.len()], - 500, + harness.set_caller(owner); + assert_eq!( + harness.initialize(owner, vec![validator], vec![ten_tokens], 1_000), + ExitCode::Ok ); - command.active_validators_length = committee.len() as u32; - command.liveness_slashing = liveness; - assert_eq!(harness.initialize_with(command), ExitCode::Ok); + staking::delegate_to(&mut harness.sdk, delegator, validator, ten_tokens, false).unwrap(); + let snapshot = staking_storage() + .validator_snapshots_accessor() + .entry(validator) + .entry(2); + snapshot + .total_blend_rewards_accessor() + .set_checked( + &mut harness.sdk, + math::narrow_reward(ten_tokens).expect("reward fits uint96"), + ) + .unwrap(); + staking_storage() + .last_rewarded_epoch_p1_accessor() + .set_checked(&mut harness.sdk, 3) + .unwrap(); - harness.set_caller(SYSTEM_CALLER); + harness.set_block_number(1_600); + let (_, output) = harness.call(encode_call( + SIG_GET_VALIDATOR_FEE, + &AddressCommand { value: validator }, + )); + assert_eq!(decode_output::(&output), DEFAULT_MIN_STAKING_AMOUNT); + + let (_, output) = harness.call(encode_call( + SIG_GET_DELEGATOR_FEE, + &ValidatorDelegatorCommand { + validator, + delegator, + }, + )); assert_eq!( - harness - .call(encode_args_call( - SIG_COMMIT_EPOCH_COMMITTEE, - &(committee.clone(),), - )) - .0, - ExitCode::Ok + decode_output::(&output), + U256::from(9) * DEFAULT_MIN_STAKING_AMOUNT / U256::from(2) ); - harness.set_caller(liveness); - for validator in &committee[..2] { - assert_eq!( - harness - .call(encode_call( - SIG_SLASH, - &AddressCommand { value: *validator }, - )) - .0, - ExitCode::Ok - ); - } + let (_, output) = harness.call(encode_call( + SIG_CALC_AVAILABLE_FOR_REDELEGATE_AMOUNT, + &ValidatorDelegatorCommand { + validator, + delegator, + }, + )); assert_eq!( - staking_storage() - .active_validators_accessor() - .len_checked(&harness.sdk), - Ok(6) + decode_output::<(U256, U256)>(&output), + ( + U256::from(9) * DEFAULT_MIN_STAKING_AMOUNT / U256::from(2), + U256::ZERO + ) ); +} +#[test] +fn committee_commit_is_system_gated_and_returns_epoch_stakes() { + let owner = Address::with_last_byte(0xa0); + let validator_a = Address::with_last_byte(0x01); + let validator_b = Address::with_last_byte(0x02); + let stake_a = U256::from(10) * DEFAULT_MIN_VALIDATOR_STAKE; + let stake_b = U256::from(20) * DEFAULT_MIN_VALIDATOR_STAKE; + let mut harness = Harness::new(1_000); + harness.set_caller(owner); assert_eq!( - harness - .call(encode_call( - SIG_SLASH, - &AddressCommand { - value: committee[2], - }, - )) - .0, + harness.initialize( + owner, + vec![validator_a, validator_b], + vec![stake_a, stake_b], + 500, + ), ExitCode::Ok ); + + let committee = (vec![validator_a, validator_b],); assert_eq!( - staking_storage() - .validators_accessor() - .entry(committee[2]) - .status_accessor() - .get_checked(&harness.sdk), - Ok(STATUS_ACTIVE), - "a seven-member committee must retain its five-member quorum floor" + harness + .call(encode_args_call(SIG_COMMIT_EPOCH_COMMITTEE, &committee)) + .0, + ExitCode::Panic ); - - let non_committee = validators[7]; + harness.set_caller(SYSTEM_CALLER); assert_eq!( harness - .call(encode_call( - SIG_SLASH, - &AddressCommand { - value: non_committee, - }, - )) + .call(encode_args_call(SIG_COMMIT_EPOCH_COMMITTEE, &committee)) .0, ExitCode::Ok ); + + let (_, output) = harness.call(encode_call( + SIG_GET_EPOCH_COMMITTEE, + &U64Command { value: 0 }, + )); assert_eq!( - staking_storage() - .validators_accessor() - .entry(non_committee) - .status_accessor() - .get_checked(&harness.sdk), - Ok(STATUS_JAIL), - "a non-committee jail must not consume the protected committee quorum" + decode_output::>(&output), + vec![validator_a, validator_b] ); + let (_, output) = harness.call(encode_call( + SIG_RESOLVE_SIGNER, + &EpochSignerCommand { + epoch: 0, + signer_idx: 1, + }, + )); + assert_eq!(decode_output::
(&output), validator_b); + let (_, output) = harness.call(encode_empty_call(SIG_NEXT_EPOCH_TO_COMMIT)); + assert_eq!(decode_output::(&output), 1); + let (_, output) = harness.call(encode_call( + SIG_GET_EPOCH_COMMITTEE_WITH_STAKES, + &U64Command { value: 0 }, + )); + let (validators, keys, stakes): (Vec
, Vec, Vec) = + decode_returns(&output); + assert_eq!(validators, vec![validator_a, validator_b]); assert_eq!( - staking_storage() - .active_validators_accessor() - .len_checked(&harness.sdk), - Ok(5) + keys.iter() + .map(|value| value.bls_pubkey[0]) + .collect::>(), + vec![0x33, 0x34] ); + assert_eq!(stakes, vec![stake_a, stake_b]); + + let logs = harness.sdk.take_logs(); + let (_, topics) = logs + .iter() + .find(|(_, topics)| { + topics.first() == Some(&B256::new(events::EpochCommitteeCommitted::SELECTOR)) + }) + .expect("committee event"); + assert_eq!(topics.len(), 2); + let (data, _) = logs + .iter() + .find(|(_, topics)| { + topics.first() == Some(&B256::new(events::EpochCommitteeCommitted::SELECTOR)) + }) + .expect("committee event"); + let (event_committee,): (Vec
,) = decode_returns(data); + assert_eq!(event_committee, vec![validator_a, validator_b]); } +// A keyless validator outranking a keyed one by stake occupies a top-k slot and +// is dropped afterwards; it is not skipped over. Ranking before filtering is what +// keeps `getValidatorsWithKeysAt` — the array the off-chain deriver builds from — +// in agreement with the committee `commitEpochCommittee` will accept. #[test] -fn jail_readmission_rejects_validator_below_minimum_self_stake() { +fn committee_verify_matches_the_selection_view_the_deriver_reads() { let owner = Address::with_last_byte(0xa0); - let liveness = Address::with_last_byte(0xb0); - let validators = (1..=4) - .map(Address::with_last_byte) - .collect::>(); - let validator = validators[0]; + let keyless = Address::with_last_byte(0x01); + let keyed_a = Address::with_last_byte(0x02); + let keyed_b = Address::with_last_byte(0x03); + let validators = vec![keyless, keyed_a, keyed_b]; let mut harness = Harness::new(1_000); let mut command = harness.initialize_command( owner, validators.clone(), - vec![DEFAULT_MIN_VALIDATOR_STAKE; validators.len()], - 0, + vec![ + DEFAULT_MIN_VALIDATOR_STAKE * U256::from(100), + DEFAULT_MIN_VALIDATOR_STAKE * U256::from(3), + DEFAULT_MIN_VALIDATOR_STAKE * U256::from(2), + ], + 500, ); - command.liveness_slashing = liveness; + command.active_validators_length = 2; assert_eq!(harness.initialize_with(command), ExitCode::Ok); - harness.set_caller(liveness); + let keyless_keys = consensus_storage().consensus_keys_accessor().entry(keyless); + keyless_keys + .peer_pubkey_accessor() + .set_checked(&mut harness.sdk, B256::ZERO) + .unwrap(); + + let (_, output) = harness.call(encode_call( + SIG_GET_VALIDATORS_WITH_KEYS_AT, + &U64Command { value: 0 }, + )); + let (view, view_keys): (Vec
, Vec) = decode_returns(&output); + assert_eq!( + view, + vec![keyless, keyed_a], + "the selection view ranks by stake before any key filtering" + ); + assert!( + view_keys[0].bls_pubkey.is_empty(), + "the keyless top-ranked validator is surfaced with blank keys, not omitted" + ); + + harness.set_caller(SYSTEM_CALLER); + assert_revert_selector( + harness.call(encode_args_call( + SIG_COMMIT_EPOCH_COMMITTEE, + &(vec![keyed_a, keyed_b],), + )), + ERR_COMMITTEE_LENGTH_MISMATCH, + ); assert_eq!( harness - .call(encode_call(SIG_SLASH, &AddressCommand { value: validator },)) + .call(encode_args_call( + SIG_COMMIT_EPOCH_COMMITTEE, + &(vec![keyed_a],), + )) .0, ExitCode::Ok ); - staking::undelegate_from( - &mut harness.sdk, - validator, - validator, - DEFAULT_MIN_VALIDATOR_STAKE, - ) - .unwrap(); - harness.set_block_number(1_200); + let (_, output) = harness.call(encode_call( + SIG_GET_EPOCH_COMMITTEE, + &U64Command { value: 0 }, + )); + assert_eq!(decode_output::>(&output), vec![keyed_a]); +} - harness.set_caller(validator); +#[test] +fn fully_keyless_committee_reverts_without_advancing_commit_pointer() { + let owner = Address::with_last_byte(0xa0); + let validators = vec![Address::with_last_byte(0x01), Address::with_last_byte(0x02)]; + let mut harness = Harness::new(1_000); + assert_eq!( + harness.initialize( + owner, + validators.clone(), + vec![DEFAULT_MIN_VALIDATOR_STAKE; validators.len()], + 500, + ), + ExitCode::Ok + ); + for validator in &validators { + consensus_storage() + .consensus_keys_accessor() + .entry(*validator) + .peer_pubkey_accessor() + .set_checked(&mut harness.sdk, B256::ZERO) + .unwrap(); + } + harness.set_caller(SYSTEM_CALLER); assert_revert_selector( - harness.call(encode_call( - SIG_RELEASE_VALIDATOR_FROM_JAIL, - &AddressCommand { value: validator }, + harness.call(encode_args_call( + SIG_COMMIT_EPOCH_COMMITTEE, + &(Vec::
::new(),), )), - ERR_OWNER_SELF_STAKE_BELOW_MINIMUM, + ERR_COMMITTEE_TOO_SMALL, ); - harness.set_caller(liveness); + let (_, output) = harness.call(encode_empty_call(SIG_NEXT_EPOCH_TO_COMMIT)); + assert_eq!(decode_output::(&output), 0); + let (_, output) = harness.call(encode_call( + SIG_GET_EPOCH_COMMITTEE_LENGTH, + &U64Command { value: 0 }, + )); + assert_eq!(decode_output::(&output), U256::ZERO); + + for (index, validator) in validators.iter().enumerate() { + let key_byte = (index + 1) as u8; + store_test_consensus_keys( + &mut harness.sdk, + *validator, + key_byte, + B256::with_last_byte(key_byte), + 0, + ); + } assert_eq!( harness - .call(encode_call( - SIG_READMIT_EXPIRED_JAILS, - &U64Command { value: 1 }, + .call(encode_args_call( + SIG_COMMIT_EPOCH_COMMITTEE, + &(validators.clone(),), )) .0, - ExitCode::Ok, - "automated readmission must skip an ineligible validator without blocking the scan" + ExitCode::Ok + ); + let (_, output) = harness.call(encode_empty_call(SIG_NEXT_EPOCH_TO_COMMIT)); + assert_eq!(decode_output::(&output), 1); +} + +#[test] +fn equal_stake_top_k_preserves_solidity_roster_order() { + let owner = Address::with_last_byte(0xa0); + let first = Address::with_last_byte(0xf0); + let second = Address::with_last_byte(0x01); + let mut harness = Harness::new(1_000); + harness.set_caller(owner); + assert_eq!( + harness.initialize( + owner, + vec![first, second], + vec![DEFAULT_MIN_VALIDATOR_STAKE, DEFAULT_MIN_VALIDATOR_STAKE], + 0, + ), + ExitCode::Ok + ); + chain_config_storage() + .active_validators_length_accessor() + .set_checked(&mut harness.sdk, 1) + .unwrap(); + + let (_, output) = harness.call(encode_empty_call(SIG_GET_VALIDATORS)); + assert_eq!(decode_output::>(&output), vec![first]); +} + +#[test] +fn selection_filters_active_validator_below_current_minimum() { + let owner = Address::with_last_byte(0xa0); + let validator = Address::with_last_byte(0x01); + let mut harness = Harness::new(1_000); + assert_eq!( + harness.initialize(owner, vec![validator], vec![DEFAULT_MIN_VALIDATOR_STAKE], 0,), + ExitCode::Ok ); + chain_config_storage() + .min_validator_stake_amount_accessor() + .set_checked( + &mut harness.sdk, + DEFAULT_MIN_VALIDATOR_STAKE * U256::from(2), + ) + .unwrap(); + assert_eq!( staking_storage() .validators_accessor() @@ -2815,8 +2969,53 @@ fn jail_readmission_rejects_validator_below_minimum_self_stake() { .status_accessor() .get_checked(&harness.sdk) .unwrap(), - STATUS_JAIL + STATUS_ACTIVE, + "selection must defend independently from lifecycle status" + ); + let (_, output) = harness.call(encode_empty_call(SIG_GET_VALIDATORS)); + assert!(decode_output::>(&output).is_empty()); + assert!(staking::selected_validators_at(&harness.sdk, 0) + .unwrap() + .is_empty()); +} + +#[test] +fn committee_pruning_keeps_dkg_history() { + let owner = Address::with_last_byte(0xa0); + let validator = Address::with_last_byte(0x01); + let activation_block = 1_000; + let mut harness = Harness::new(activation_block); + harness.set_caller(owner); + assert_eq!( + harness.initialize(owner, vec![validator], vec![DEFAULT_MIN_VALIDATOR_STAKE], 0,), + ExitCode::Ok + ); + harness.set_block_number( + activation_block + + DEFAULT_EPOCH_BLOCK_INTERVAL + * (DEFAULT_UNDELEGATE_PERIOD + EPOCH_COMMITTEE_RETENTION_MARGIN + 2), + ); + consensus_storage() + .dkg_qual_accessor() + .entry(1) + .set_checked(&mut harness.sdk, true) + .unwrap(); + + harness.set_caller(SYSTEM_CALLER); + assert_eq!( + harness + .call(encode_args_call( + SIG_COMMIT_EPOCH_COMMITTEE, + &(vec![validator],), + )) + .0, + ExitCode::Ok ); + assert!(consensus_storage() + .dkg_qual_accessor() + .entry(1) + .get_checked(&harness.sdk) + .unwrap()); } #[test] @@ -2848,13 +3047,6 @@ fn chain_config_guards_match_solidity_boundaries() { )), ERR_ZERO_VALUE, ); - assert_revert_selector( - harness.call(encode_call( - SIG_SET_PARTICIPATION_FLOOR_BPS, - &U32Command { value: 2_001 }, - )), - ERR_PARTICIPATION_FLOOR_BPS_TOO_HIGH, - ); assert_revert_selector( harness.call(encode_call( SIG_SET_BLEND_STIPEND_PER_EPOCH, @@ -3298,49 +3490,366 @@ fn zero_reserve_balance_skips_without_disbursement_call() { assert_stipend_events(&harness.sdk, 0, U256::ZERO, true); } -#[test] -fn failed_or_malformed_disbursement_remains_retryable() { - for (first_response, expected_exit) in [ - ( - MockDisbursement::EmptyReturn, - ExitCode::MalformedBuiltinParams, - ), - (MockDisbursement::Revert, ExitCode::Panic), - ] { - let assigned = U256::from(100); - let (mut harness, calls, validator) = stipend_test_sdk( - vec![assigned, assigned], - vec![first_response, MockDisbursement::Amount(assigned)], - ); +fn install_solvent_reserve(sdk: &TestingContextImpl, reserve: Address, balance: U256) { + sdk.set_call_handler(move |address, _value, input, _fuel_limit| { + if input.len() < SIG_LEN_BYTES { + return SyscallResult::new(Bytes::new(), 0, 0, ExitCode::MalformedBuiltinParams); + } + let selector = u32::from_be_bytes(input[..SIG_LEN_BYTES].try_into().unwrap()); + match (address, selector) { + (address, SIG_RESERVE_BALANCE) if address == reserve => { + SyscallResult::new(encode_mock_return(&balance), 0, 0, ExitCode::Ok) + } + (address, SIG_RESERVE_DISBURSE) if address == reserve => { + let (_, assigned) = + SolidityABI::<(Address, U256)>::decode(&&input[SIG_LEN_BYTES..], 0).unwrap(); + SyscallResult::new(encode_mock_return(&assigned), 0, 0, ExitCode::Ok) + } + _ => SyscallResult::new(Bytes::new(), 0, 0, ExitCode::Panic), + } + }); +} - assert_eq!( - harness - .call(encode_call( - SIG_SETTLE_EPOCH_STIPEND, - &U64Command { value: 0 }, - )) - .0, - expected_exit - ); - assert_eq!( - stipend_accounting(&harness.sdk, validator), - (U256::ZERO, U256::ZERO, 0) - ); - assert!(harness.sdk.take_logs().is_empty()); +fn epoch_reward(sdk: &TestingContextImpl, validator: Address, epoch: u64) -> U256 { + U256::from( + staking_storage() + .validator_snapshots_accessor() + .entry(validator) + .entry(epoch) + .total_blend_rewards_accessor() + .get_checked(sdk) + .unwrap(), + ) +} + +#[test] +fn stipend_pays_the_frozen_weights_not_the_stake_at_settlement_time() { + let owner = Address::with_last_byte(0xa0); + let delegator = Address::with_last_byte(0xd0); + let reserve = Address::with_last_byte(0xc0); + let validator_a = Address::with_last_byte(0x01); + let validator_b = Address::with_last_byte(0x02); + let stake = DEFAULT_MIN_VALIDATOR_STAKE; + let mut harness = Harness::new(1_000); + let mut command = + harness.initialize_command(owner, vec![validator_a, validator_b], vec![stake, stake], 0); + command.blend_reserve = reserve; + assert_eq!(harness.initialize_with(command), ExitCode::Ok); + harness.set_caller(SYSTEM_CALLER); + for _ in 0..3 { assert_eq!( harness - .call(encode_call( - SIG_SETTLE_EPOCH_STIPEND, - &U64Command { value: 0 }, + .call(encode_args_call( + SIG_COMMIT_EPOCH_COMMITTEE, + &(vec![validator_a, validator_b],), )) .0, ExitCode::Ok ); - assert_eq!( - stipend_accounting(&harness.sdk, validator), - (assigned, assigned, 1) - ); + } + // Effective from epoch 2, so a live walk at the settled epoch would weight + // the committee 4:1 where the epoch-0 freeze weights it 1:1. + staking::delegate_to( + &mut harness.sdk, + delegator, + validator_a, + stake * U256::from(3), + false, + ) + .unwrap(); + + chain_config_storage() + .blend_stipend_per_epoch_accessor() + .set_checked(&mut harness.sdk, U256::from(100)) + .unwrap(); + staking_storage() + .last_rewarded_epoch_p1_accessor() + .set_checked(&mut harness.sdk, 2) + .unwrap(); + install_solvent_reserve(&harness.sdk, reserve, U256::from(100)); + record_test_production(&mut harness.sdk, 2, DEFAULT_EPOCH_BLOCK_INTERVAL as u32); + + harness.set_block_number(1_000 + 3 * DEFAULT_EPOCH_BLOCK_INTERVAL); + harness.set_caller(SYSTEM_CALLER); + assert_eq!( + harness + .call(encode_call( + SIG_SETTLE_EPOCH_STIPEND, + &U64Command { value: 2 }, + )) + .0, + ExitCode::Ok + ); + assert_eq!(epoch_reward(&harness.sdk, validator_a, 2), U256::from(50)); + assert_eq!(epoch_reward(&harness.sdk, validator_b, 2), U256::from(50)); +} + +// A committee may be committed two epochs ahead, so the weights an unfinished +// epoch would be paid on already exist. Paying it draws a full pot for an epoch +// with no production and advances the cursor past it for good. +#[test] +fn an_epoch_that_has_not_finished_cannot_be_settled() { + let owner = Address::with_last_byte(0xa0); + let validator = Address::with_last_byte(0x01); + let reserve = Address::with_last_byte(0xc0); + let stake = DEFAULT_MIN_VALIDATOR_STAKE; + let mut harness = Harness::new(1_000); + let mut command = harness.initialize_command(owner, vec![validator], vec![stake], 0); + command.blend_reserve = reserve; + assert_eq!(harness.initialize_with(command), ExitCode::Ok); + + commit_test_committee(&mut harness.sdk, 0, &[(validator, stake)]); + commit_test_committee(&mut harness.sdk, 1, &[(validator, stake)]); + chain_config_storage() + .blend_stipend_per_epoch_accessor() + .set_checked(&mut harness.sdk, U256::from(100)) + .unwrap(); + install_solvent_reserve(&harness.sdk, reserve, U256::from(1_000)); + record_test_production(&mut harness.sdk, 0, DEFAULT_EPOCH_BLOCK_INTERVAL as u32); + record_test_production(&mut harness.sdk, 1, DEFAULT_EPOCH_BLOCK_INTERVAL as u32); + + harness.set_caller(SYSTEM_CALLER); + assert_eq!( + harness + .call(encode_call( + SIG_SETTLE_EPOCH_STIPEND, + &U64Command { value: 1 }, + )) + .0, + ExitCode::Ok + ); + assert_eq!(epoch_reward(&harness.sdk, validator, 0), U256::ZERO); + assert_eq!(epoch_reward(&harness.sdk, validator, 1), U256::ZERO); + assert_eq!( + staking_storage() + .last_rewarded_epoch_p1_accessor() + .get_checked(&harness.sdk) + .unwrap(), + 0, + "the cursor must not advance past an epoch that was never paid" + ); + + harness.set_block_number(1_000 + DEFAULT_EPOCH_BLOCK_INTERVAL); + harness.set_caller(SYSTEM_CALLER); + assert_eq!( + harness + .call(encode_call( + SIG_SETTLE_EPOCH_STIPEND, + &U64Command { value: 1 }, + )) + .0, + ExitCode::Ok + ); + assert_eq!(epoch_reward(&harness.sdk, validator, 0), U256::from(100)); + assert_eq!( + epoch_reward(&harness.sdk, validator, 1), + U256::ZERO, + "epoch 1 is still running and stays unpaid" + ); +} + +// The cursor walks epochs contiguously, so a gap in production — a stalled +// recorder, a pre-activation prefix — is passed over by a LATER epoch's close. +// Without a per-epoch belt each skipped epoch draws a full pot for no blocks. +#[test] +fn an_epoch_that_recorded_no_blocks_is_skipped_when_a_later_one_settles() { + let owner = Address::with_last_byte(0xa0); + let validator = Address::with_last_byte(0x01); + let reserve = Address::with_last_byte(0xc0); + let stake = DEFAULT_MIN_VALIDATOR_STAKE; + let mut harness = Harness::new(1_000); + let mut command = harness.initialize_command(owner, vec![validator], vec![stake], 0); + command.blend_reserve = reserve; + assert_eq!(harness.initialize_with(command), ExitCode::Ok); + + for epoch in 0..3 { + commit_test_committee(&mut harness.sdk, epoch, &[(validator, stake)]); + } + chain_config_storage() + .blend_stipend_per_epoch_accessor() + .set_checked(&mut harness.sdk, U256::from(100)) + .unwrap(); + install_solvent_reserve(&harness.sdk, reserve, U256::from(1_000)); + // Epochs 0 and 1 recorded nothing; only epoch 2 produced blocks. + record_test_production(&mut harness.sdk, 2, DEFAULT_EPOCH_BLOCK_INTERVAL as u32); + + harness.set_block_number(1_000 + 3 * DEFAULT_EPOCH_BLOCK_INTERVAL); + harness.set_caller(SYSTEM_CALLER); + assert_eq!( + harness + .call(encode_call( + SIG_SETTLE_EPOCH_STIPEND, + &U64Command { value: 2 }, + )) + .0, + ExitCode::Ok + ); + + assert_eq!(epoch_reward(&harness.sdk, validator, 0), U256::ZERO); + assert_eq!(epoch_reward(&harness.sdk, validator, 1), U256::ZERO); + assert_eq!( + epoch_reward(&harness.sdk, validator, 2), + U256::from(100), + "only the epoch that recorded blocks is paid" + ); + assert_eq!( + staking_storage() + .credited_blend_accessor() + .get_checked(&harness.sdk) + .unwrap(), + U256::from(100), + "the skipped epochs must not have drawn a pot each" + ); +} + +// Truncating to the shorter of the two arrays would hand the whole pot to a +// committee prefix and then advance the cursor past the epoch for good, so the +// settle path must refuse a mismatch exactly as the reader does. +#[test] +fn settlement_rejects_a_committee_without_matching_frozen_weights() { + let owner = Address::with_last_byte(0xa0); + let seated = Address::with_last_byte(0x01); + let unweighted = Address::with_last_byte(0x02); + let reserve = Address::with_last_byte(0xc0); + let stake = DEFAULT_MIN_VALIDATOR_STAKE; + let mut harness = Harness::new(1_000); + let mut command = + harness.initialize_command(owner, vec![seated, unweighted], vec![stake, stake], 0); + command.blend_reserve = reserve; + assert_eq!(harness.initialize_with(command), ExitCode::Ok); + + // Two committee members, one frozen weight. + let consensus = consensus_storage(); + let committee = consensus.epoch_committees_accessor().entry(0); + committee.push_checked(&mut harness.sdk, seated).unwrap(); + committee.push_checked(&mut harness.sdk, unweighted).unwrap(); + consensus + .leader_stakes_accessor() + .entry(0) + .push_checked(&mut harness.sdk, crate::math::compact_balance(stake).unwrap()) + .unwrap(); + chain_config_storage() + .blend_stipend_per_epoch_accessor() + .set_checked(&mut harness.sdk, U256::from(100)) + .unwrap(); + install_solvent_reserve(&harness.sdk, reserve, U256::from(1_000)); + record_test_production(&mut harness.sdk, 0, DEFAULT_EPOCH_BLOCK_INTERVAL as u32); + + harness.set_block_number(1_000 + DEFAULT_EPOCH_BLOCK_INTERVAL); + harness.set_caller(SYSTEM_CALLER); + assert_revert_selector( + harness.call(encode_call( + SIG_SETTLE_EPOCH_STIPEND, + &U64Command { value: 0 }, + )), + ERR_LEADER_STAKES_LENGTH_MISMATCH, + ); + assert_eq!( + staking_storage() + .last_rewarded_epoch_p1_accessor() + .get_checked(&harness.sdk) + .unwrap(), + 0, + "a refused settlement must not advance the cursor past the epoch" + ); +} + +#[test] +fn tombstoned_committee_member_earns_no_stipend_share() { + let owner = Address::with_last_byte(0xa0); + let reserve = Address::with_last_byte(0xc0); + let validator_a = Address::with_last_byte(0x01); + let validator_b = Address::with_last_byte(0x02); + let stake = DEFAULT_MIN_VALIDATOR_STAKE; + let mut harness = Harness::new(1_000); + let mut command = + harness.initialize_command(owner, vec![validator_a, validator_b], vec![stake, stake], 0); + command.blend_reserve = reserve; + assert_eq!(harness.initialize_with(command), ExitCode::Ok); + + commit_test_committee( + &mut harness.sdk, + 0, + &[(validator_a, stake), (validator_b, stake)], + ); + consensus_storage() + .tombstoned_accessor() + .entry(validator_a) + .set_checked(&mut harness.sdk, true) + .unwrap(); + chain_config_storage() + .blend_stipend_per_epoch_accessor() + .set_checked(&mut harness.sdk, U256::from(100)) + .unwrap(); + install_solvent_reserve(&harness.sdk, reserve, U256::from(100)); + record_test_production(&mut harness.sdk, 0, DEFAULT_EPOCH_BLOCK_INTERVAL as u32); + + harness.set_block_number(1_000 + DEFAULT_EPOCH_BLOCK_INTERVAL); + harness.set_caller(SYSTEM_CALLER); + assert_eq!( + harness + .call(encode_call( + SIG_SETTLE_EPOCH_STIPEND, + &U64Command { value: 0 }, + )) + .0, + ExitCode::Ok + ); + assert_eq!(epoch_reward(&harness.sdk, validator_a, 0), U256::ZERO); + assert_eq!(epoch_reward(&harness.sdk, validator_b, 0), U256::from(100)); + assert_eq!( + staking_storage() + .credited_blend_accessor() + .get_checked(&harness.sdk) + .unwrap(), + U256::from(100) + ); +} + +#[test] +fn failed_or_malformed_disbursement_remains_retryable() { + for (first_response, expected_exit) in [ + ( + MockDisbursement::EmptyReturn, + ExitCode::MalformedBuiltinParams, + ), + (MockDisbursement::Revert, ExitCode::Panic), + ] { + let assigned = U256::from(100); + let (mut harness, calls, validator) = stipend_test_sdk( + vec![assigned, assigned], + vec![first_response, MockDisbursement::Amount(assigned)], + ); + + assert_eq!( + harness + .call(encode_call( + SIG_SETTLE_EPOCH_STIPEND, + &U64Command { value: 0 }, + )) + .0, + expected_exit + ); + assert_eq!( + stipend_accounting(&harness.sdk, validator), + (U256::ZERO, U256::ZERO, 0) + ); + assert!(harness.sdk.take_logs().is_empty()); + + assert_eq!( + harness + .call(encode_call( + SIG_SETTLE_EPOCH_STIPEND, + &U64Command { value: 0 }, + )) + .0, + ExitCode::Ok + ); + assert_eq!( + stipend_accounting(&harness.sdk, validator), + (assigned, assigned, 1) + ); assert_eq!(calls.borrow().disburse_calls.len(), 2); assert_stipend_events(&harness.sdk, 0, assigned, false); } @@ -3621,138 +4130,20 @@ fn reward_claims_are_bounded_to_one_thousand_epochs() { } #[test] -fn liveness_halt_guard_and_equivocation_tombstone_are_enforced() { +fn committee_validation_is_canonical_and_membership_changes_mint_dkg_bit() { let owner = Address::with_last_byte(0xa0); - let liveness = Address::with_last_byte(0xb0); - let validators = (1..=4) - .map(Address::with_last_byte) - .collect::>(); + let validator_a = Address::with_last_byte(0x01); + let validator_b = Address::with_last_byte(0x02); let mut harness = Harness::new(1_000); harness.set_caller(owner); - let mut command = harness.initialize_command( - owner, - validators.clone(), - vec![DEFAULT_MIN_VALIDATOR_STAKE; validators.len()], - 500, - ); - command.liveness_slashing = liveness; - command.blend_reserve = Address::with_last_byte(0xc0); - assert_eq!(harness.initialize_with(command), ExitCode::Ok); - harness.set_caller(liveness); - for validator in &validators[..2] { - assert_eq!( - harness - .call(encode_call( - SIG_SLASH, - &AddressCommand { value: *validator }, - )) - .0, - ExitCode::Ok - ); - } assert_eq!( - staking_storage() - .validators_accessor() - .entry(validators[0]) - .status_accessor() - .get_checked(&harness.sdk) - .unwrap(), - STATUS_JAIL - ); - assert_eq!( - staking_storage() - .validators_accessor() - .entry(validators[1]) - .status_accessor() - .get_checked(&harness.sdk) - .unwrap(), - STATUS_ACTIVE, - "the second jail would drop the active set below Simplex quorum" - ); - - let jailed_record = staking_storage().validators_accessor().entry(validators[0]); - let initial_deadline = jailed_record - .jailed_before_accessor() - .get_checked(&harness.sdk) - .unwrap(); - harness.set_block_number(1_200); - assert_eq!( - harness - .call(encode_call( - SIG_SLASH, - &AddressCommand { - value: validators[0], - }, - )) - .0, - ExitCode::Ok - ); - let extended_deadline = jailed_record - .jailed_before_accessor() - .get_checked(&harness.sdk) - .unwrap(); - assert!( - extended_deadline > initial_deadline, - "re-slashing a jailed validator must extend its deadline despite the quorum guard" - ); - - chain_config_storage() - .validator_jail_epoch_length_accessor() - .set_checked(&mut harness.sdk, 0) - .unwrap(); - harness.set_block_number(1_300); - assert_eq!( - harness - .call(encode_call( - SIG_SLASH, - &AddressCommand { - value: validators[0], - }, - )) - .0, - ExitCode::Ok - ); - assert_eq!( - jailed_record - .jailed_before_accessor() - .get_checked(&harness.sdk) - .unwrap(), - extended_deadline, - "a shorter jail configuration must not reduce an existing deadline" - ); - - consensus_storage() - .tombstoned_accessor() - .entry(validators[0]) - .set_checked(&mut harness.sdk, true) - .unwrap(); - harness.set_caller(validators[0]); - assert_revert_selector( - harness.call(encode_call( - SIG_RELEASE_VALIDATOR_FROM_JAIL, - &AddressCommand { - value: validators[0], - }, - )), - ERR_ALREADY_SLASHED_FOR_EQUIVOCATION, - ); -} - -#[test] -fn committee_validation_is_canonical_and_membership_changes_mint_dkg_bit() { - let owner = Address::with_last_byte(0xa0); - let validator_a = Address::with_last_byte(0x01); - let validator_b = Address::with_last_byte(0x02); - let mut harness = Harness::new(1_000); - harness.set_caller(owner); - assert_eq!( - harness.initialize( - owner, - vec![validator_a, validator_b], - vec![DEFAULT_MIN_VALIDATOR_STAKE, DEFAULT_MIN_VALIDATOR_STAKE,], - 500, - ), - ExitCode::Ok + harness.initialize( + owner, + vec![validator_a, validator_b], + vec![DEFAULT_MIN_VALIDATOR_STAKE, DEFAULT_MIN_VALIDATOR_STAKE,], + 500, + ), + ExitCode::Ok ); harness.set_caller(SYSTEM_CALLER); @@ -4284,227 +4675,1563 @@ fn equivocation_seizes_active_and_pending_self_delegation() { .0, ExitCode::Ok ); - assert_eq!(transfers.borrow().len(), 2); + assert_eq!(transfers.borrow().len(), 2); +} + +#[test] +fn continuously_seated_validator_self_stake_unlocks_at_bounded_liability_deadline() { + let owner = Address::with_last_byte(0xa0); + let validator = Address::with_last_byte(0x01); + let token = Address::with_last_byte(0xc0); + let withdrawn = DEFAULT_MIN_VALIDATOR_STAKE; + let stake = withdrawn * U256::from(2); + let activation_block = 1_000; + let mut harness = Harness::new(activation_block); + harness.set_caller(owner); + assert_eq!( + harness.initialize(owner, vec![validator], vec![stake], 500), + ExitCode::Ok + ); + chain_config_storage() + .staking_token_accessor() + .set_checked(&mut harness.sdk, token) + .unwrap(); + + harness.set_caller(SYSTEM_CALLER); + assert_eq!( + harness + .call(encode_args_call( + SIG_COMMIT_EPOCH_COMMITTEE, + &(vec![validator],), + )) + .0, + ExitCode::Ok + ); + + staking::undelegate_from(&mut harness.sdk, validator, validator, withdrawn).unwrap(); + let delegation = staking_storage() + .validator_delegations_accessor() + .entry(validator) + .entry(validator); + let undelegates = delegation.undelegate_queue_accessor(); + let maturity_epoch = DEFAULT_UNDELEGATE_PERIOD + 1; + let liability_end_epoch = + maturity_epoch + EPOCH_COMMITTEE_RETENTION_MARGIN + MAX_COMMITTEE_LOOKAHEAD_EPOCHS; + assert_eq!( + undelegates + .at(0) + .epoch_accessor() + .get_checked(&harness.sdk) + .unwrap(), + maturity_epoch + ); + assert_eq!( + undelegates + .at(0) + .self_stake_unlock_epoch_accessor() + .get_checked(&harness.sdk) + .unwrap(), + liability_end_epoch + ); + assert_eq!( + staking_storage() + .validators_accessor() + .entry(validator) + .self_stake_unlock_epoch_accessor() + .get_checked(&harness.sdk) + .unwrap(), + liability_end_epoch + ); + + let transfers = Rc::new(RefCell::new(Vec::<(Address, U256)>::new())); + let recorded = transfers.clone(); + harness + .sdk + .set_call_handler(move |address, _value, input, _fuel_limit| { + assert_eq!(address, token); + assert_eq!( + u32::from_be_bytes(input[..SIG_LEN_BYTES].try_into().unwrap()), + SIG_ERC20_TRANSFER + ); + let transfer = + SolidityABI::<(Address, U256)>::decode(&&input[SIG_LEN_BYTES..], 0).unwrap(); + recorded.borrow_mut().push(transfer); + SyscallResult::new(Bytes::new(), 0, 0, ExitCode::Ok) + }); + + // Keep committing this still-active validator after its partial self-exit. + // New committees are secured by the remaining self-stake and must not keep + // extending the already-queued principal's fixed liability deadline. + harness.set_caller(SYSTEM_CALLER); + assert_eq!( + harness + .call(encode_args_call( + SIG_COMMIT_EPOCH_COMMITTEE, + &(vec![validator],), + )) + .0, + ExitCode::Ok + ); + assert_eq!( + harness + .call(encode_args_call( + SIG_COMMIT_EPOCH_COMMITTEE, + &(vec![validator],), + )) + .0, + ExitCode::Ok + ); + for epoch in 1..liability_end_epoch { + harness.set_block_number(activation_block + DEFAULT_EPOCH_BLOCK_INTERVAL * epoch); + harness.set_caller(SYSTEM_CALLER); + assert_eq!( + harness + .call(encode_args_call( + SIG_COMMIT_EPOCH_COMMITTEE, + &(vec![validator],), + )) + .0, + ExitCode::Ok + ); + + if epoch == maturity_epoch { + harness.set_caller(validator); + let (exit, output) = harness.call(encode_call( + SIG_GET_VALIDATOR_SELF_STAKE_LOCK, + &AddressCommand { value: validator }, + )); + assert_eq!(exit, ExitCode::Ok); + assert_eq!( + decode_output::<(bool, u64)>(&output), + (true, liability_end_epoch) + ); + consensus::ensure_equivocation_evidence_unexpired(&mut harness.sdk, 2).unwrap(); + assert_eq!( + harness + .call(encode_call( + SIG_CLAIM_DELEGATOR_FEE, + &AddressCommand { value: validator }, + )) + .0, + ExitCode::Ok + ); + assert!(transfers.borrow().is_empty()); + assert_eq!( + delegation + .undelegate_gap_accessor() + .get_checked(&harness.sdk) + .unwrap(), + 0 + ); + } + } + + harness.set_block_number(activation_block + DEFAULT_EPOCH_BLOCK_INTERVAL * liability_end_epoch); + assert_eq!( + staking_storage() + .validators_accessor() + .entry(validator) + .status_accessor() + .get_checked(&harness.sdk) + .unwrap(), + STATUS_ACTIVE + ); + assert!( + staking::selected_validators_at(&harness.sdk, liability_end_epoch) + .unwrap() + .contains(&validator) + ); + assert_eq!( + consensus_storage() + .epoch_committees_accessor() + .entry(2) + .len_checked(&harness.sdk) + .unwrap(), + 1, + "time-based release must not depend on a later committee commit pruning storage" + ); + harness.set_caller(validator); + let (exit, output) = harness.call(encode_call( + SIG_GET_VALIDATOR_SELF_STAKE_LOCK, + &AddressCommand { value: validator }, + )); + assert_eq!(exit, ExitCode::Ok); + assert_eq!( + decode_output::<(bool, u64)>(&output), + (false, liability_end_epoch) + ); + assert_direct_revert( + consensus::ensure_equivocation_evidence_unexpired(&mut harness.sdk, 2), + &harness.sdk, + ERR_EQUIVOCATION_EVIDENCE_EXPIRED, + ); + assert_eq!( + harness + .call(encode_call( + SIG_CLAIM_DELEGATOR_FEE, + &AddressCommand { value: validator }, + )) + .0, + ExitCode::Ok + ); + assert_eq!(transfers.borrow().as_slice(), &[(validator, withdrawn)]); + assert_eq!( + delegation + .pending_undelegated_accessor() + .get_checked(&harness.sdk) + .unwrap(), + U256::ZERO + ); + assert_eq!( + delegation + .undelegate_gap_accessor() + .get_checked(&harness.sdk) + .unwrap(), + 1 + ); + assert_eq!( + staking_storage() + .validators_accessor() + .entry(validator) + .self_stake_unlock_epoch_accessor() + .get_checked(&harness.sdk) + .unwrap(), + 0 + ); +} + +#[test] +fn production_liveness_ships_disabled_on_a_fresh_chain() { + let owner = Address::with_last_byte(0xa0); + let mut harness = Harness::new(1_000); + assert_eq!( + harness.initialize(owner, Vec::new(), Vec::new(), 0), + ExitCode::Ok + ); + + let (exit, output) = harness.call(encode_empty_call(SIG_GET_PRODUCTION_LIVENESS_DISABLED)); + assert_eq!(exit, ExitCode::Ok); + assert!( + decode_output::(&output), + "an unwritten slot reads false, so the tier would ship ON unless init seeds it" + ); + + for (selector, expected) in [ + (SIG_GET_MIN_VERDICT_DUE_BLOCKS, DEFAULT_MIN_VERDICT_DUE_BLOCKS), + (SIG_GET_EXCLUSION_BACKOFF_CAP, DEFAULT_EXCLUSION_BACKOFF_CAP), + ( + SIG_DEFAULT_MIN_VERDICT_DUE_BLOCKS, + DEFAULT_MIN_VERDICT_DUE_BLOCKS, + ), + ( + SIG_DEFAULT_EXCLUSION_BACKOFF_CAP, + DEFAULT_EXCLUSION_BACKOFF_CAP, + ), + (SIG_MAX_MIN_VERDICT_DUE_BLOCKS, MAX_MIN_VERDICT_DUE_BLOCKS), + ] { + let (exit, output) = harness.call(encode_empty_call(selector)); + assert_eq!(exit, ExitCode::Ok); + assert_eq!(decode_output::(&output), expected); + } + + let logs = harness.sdk.take_logs(); + let disabled_data = &logs + .iter() + .find(|(_, topics)| { + topics.first() == Some(&B256::new(events::ProductionLivenessDisabledChanged::SELECTOR)) + }) + .expect("kill-switch seed event") + .0; + assert_eq!(decode_output::<(bool, bool)>(disabled_data), (false, true)); +} + +#[test] +fn production_liveness_setters_enforce_their_bounds() { + let owner = Address::with_last_byte(0xa0); + let outsider = Address::with_last_byte(0xb0); + let mut harness = Harness::new(1_000); + assert_eq!( + harness.initialize(owner, Vec::new(), Vec::new(), 0), + ExitCode::Ok + ); + + harness.set_caller(outsider); + for selector in [ + SIG_SET_MIN_VERDICT_DUE_BLOCKS, + SIG_SET_EXCLUSION_BACKOFF_CAP, + ] { + assert_revert_selector( + harness.call(encode_call(selector, &U32Command { value: 5 })), + ERR_ONLY_GOVERNANCE, + ); + } + assert_revert_selector( + harness.call(encode_call( + SIG_SET_PRODUCTION_LIVENESS_DISABLED, + &BoolCommand { value: false }, + )), + ERR_ONLY_GOVERNANCE, + ); + + harness.set_caller(GENESIS_GOVERNANCE); + for selector in [ + SIG_SET_MIN_VERDICT_DUE_BLOCKS, + SIG_SET_EXCLUSION_BACKOFF_CAP, + ] { + assert_revert_selector( + harness.call(encode_call(selector, &U32Command { value: 0 })), + ERR_ZERO_VALUE, + ); + } + assert_revert_selector( + harness.call(encode_call( + SIG_SET_MIN_VERDICT_DUE_BLOCKS, + &U32Command { + value: MAX_MIN_VERDICT_DUE_BLOCKS + 1, + }, + )), + ERR_MIN_VERDICT_DUE_BLOCKS_TOO_HIGH, + ); + assert_eq!( + harness + .call(encode_call( + SIG_SET_MIN_VERDICT_DUE_BLOCKS, + &U32Command { + value: MAX_MIN_VERDICT_DUE_BLOCKS, + }, + )) + .0, + ExitCode::Ok, + "the ceiling is inclusive" + ); + assert_eq!( + harness + .call(encode_call( + SIG_SET_EXCLUSION_BACKOFF_CAP, + &U32Command { value: u32::MAX }, + )) + .0, + ExitCode::Ok, + "the ladder ceiling is bounded away from zero only" + ); + assert_eq!( + harness + .call(encode_call( + SIG_SET_PRODUCTION_LIVENESS_DISABLED, + &BoolCommand { value: false }, + )) + .0, + ExitCode::Ok + ); + + let (_, output) = harness.call(encode_empty_call(SIG_GET_MIN_VERDICT_DUE_BLOCKS)); + assert_eq!(decode_output::(&output), MAX_MIN_VERDICT_DUE_BLOCKS); + let (_, output) = harness.call(encode_empty_call(SIG_GET_EXCLUSION_BACKOFF_CAP)); + assert_eq!(decode_output::(&output), u32::MAX); + let (_, output) = harness.call(encode_empty_call(SIG_GET_PRODUCTION_LIVENESS_DISABLED)); + assert!(!decode_output::(&output)); +} + +// A roster member that is selection-visible but below the minimum self-stake can +// never be seated, so it is not a replacement. Counting visibility alone +// over-reports the pool and hands out a stamp whose seat then simply vanishes. +// The Solidity has no such filter, so this gap exists only in the port. +#[test] +fn exclusion_does_not_count_a_validator_that_cannot_be_seated_as_a_replacement() { + let owner = Address::with_last_byte(0xa0); + let rich = Address::with_last_byte(0x01); + let middle = Address::with_last_byte(0x02); + let poor = Address::with_last_byte(0x03); + let mut harness = Harness::new(1_000); + let mut command = harness.initialize_command( + owner, + vec![rich, middle, poor], + vec![ + DEFAULT_MIN_VALIDATOR_STAKE * U256::from(5), + DEFAULT_MIN_VALIDATOR_STAKE * U256::from(3), + DEFAULT_MIN_VALIDATOR_STAKE, + ], + 0, + ); + command.active_validators_length = 2; + assert_eq!(harness.initialize_with(command), ExitCode::Ok); + + // `poor` stays visible but drops below the bar, so the eligible pool is 2 — + // exactly the cap, leaving nothing to replace an excluded member with. + harness.set_caller(GENESIS_GOVERNANCE); + assert_eq!( + harness + .call(encode_call( + SIG_SET_MIN_VALIDATOR_STAKE_AMOUNT, + &U256Command { + value: DEFAULT_MIN_VALIDATOR_STAKE * U256::from(2), + }, + )) + .0, + ExitCode::Ok + ); + + let next = crate::util::next_epoch(&harness.sdk).unwrap(); + assert_eq!( + staking::selected_validators_at(&harness.sdk, next).unwrap(), + vec![rich, middle], + "the pool that can actually be seated is already at the cap" + ); + + harness.sdk.take_logs(); + assert!( + !staking::apply_production_exclusion(&mut harness.sdk, middle).unwrap(), + "an unseatable roster member is not a replacement" + ); + assert!(harness.sdk.take_logs().is_empty()); + assert_eq!( + staking::selected_validators_at(&harness.sdk, next).unwrap(), + vec![rich, middle], + "the refused stamp must not have shrunk the committee" + ); +} + +#[test] +fn production_exclusion_refuses_without_a_replacement_and_leaves_no_trace() { + let owner = Address::with_last_byte(0xa0); + let first = Address::with_last_byte(0x01); + let second = Address::with_last_byte(0x02); + let mut harness = Harness::new(1_000); + let mut command = harness.initialize_command( + owner, + vec![first, second], + vec![ + DEFAULT_MIN_VALIDATOR_STAKE * U256::from(3), + DEFAULT_MIN_VALIDATOR_STAKE * U256::from(2), + ], + 0, + ); + command.active_validators_length = 2; + assert_eq!(harness.initialize_with(command), ExitCode::Ok); + harness.sdk.take_logs(); + + assert!( + !staking::apply_production_exclusion(&mut harness.sdk, second).unwrap(), + "the visible pool only equals the cap, so excluding a member shrinks the committee" + ); + assert!(staking::selection_visible_at(&harness.sdk, second, 1).unwrap()); + assert_eq!( + staking::selected_validators_at(&harness.sdk, 1).unwrap(), + vec![first, second] + ); + assert!( + harness.sdk.take_logs().is_empty(), + "a refused stamp must leave no trace at all" + ); +} + +#[test] +fn production_exclusion_bites_at_the_next_epoch_and_not_before() { + let owner = Address::with_last_byte(0xa0); + let first = Address::with_last_byte(0x01); + let second = Address::with_last_byte(0x02); + let third = Address::with_last_byte(0x03); + let mut harness = Harness::new(1_000); + let mut command = harness.initialize_command( + owner, + vec![first, second, third], + vec![ + DEFAULT_MIN_VALIDATOR_STAKE * U256::from(3), + DEFAULT_MIN_VALIDATOR_STAKE * U256::from(2), + DEFAULT_MIN_VALIDATOR_STAKE, + ], + 0, + ); + command.active_validators_length = 2; + assert_eq!(harness.initialize_with(command), ExitCode::Ok); + harness.sdk.take_logs(); + + assert!(staking::apply_production_exclusion(&mut harness.sdk, second).unwrap()); + assert!( + staking::selection_visible_at(&harness.sdk, second, 0).unwrap(), + "epoch 0 has already started and its selection view must not be rewritten" + ); + assert!(!staking::selection_visible_at(&harness.sdk, second, 1).unwrap()); + assert_eq!( + staking::selected_validators_at(&harness.sdk, 0).unwrap(), + vec![first, second] + ); + assert_eq!( + staking::selected_validators_at(&harness.sdk, 1).unwrap(), + vec![first, third], + "the freed seat is taken by the replacement the refusal rule required" + ); + + let logs = harness.sdk.take_logs(); + let (data, topics) = logs + .iter() + .find(|(_, topics)| { + topics.first() == Some(&B256::new(events::ProductionExclusionApplied::SELECTOR)) + }) + .expect("exclusion event"); + assert_eq!(&topics[1].0[12..], second.as_slice()); + assert_eq!(decode_output::(data), 1); + + harness.sdk.take_logs(); + assert!( + !staking::apply_production_exclusion(&mut harness.sdk, second).unwrap(), + "a member already invisible at the bite epoch is refused rather than re-stamped" + ); + assert!( + harness.sdk.take_logs().is_empty(), + "a repeat stamp leaves no trace either" + ); +} + +#[test] +fn exclusion_release_skips_tombstoned_and_non_active_validators() { + let owner = Address::with_last_byte(0xa0); + let keeper = Address::with_last_byte(0x01); + let tombstoned = Address::with_last_byte(0x02); + let demoted = Address::with_last_byte(0x03); + let healthy = Address::with_last_byte(0x04); + let mut harness = Harness::new(1_000); + let mut command = harness.initialize_command( + owner, + vec![keeper, tombstoned, demoted, healthy], + vec![DEFAULT_MIN_VALIDATOR_STAKE * U256::from(4); 4], + 0, + ); + command.active_validators_length = 1; + assert_eq!(harness.initialize_with(command), ExitCode::Ok); + + for validator in [tombstoned, demoted, healthy] { + assert!(staking::apply_production_exclusion(&mut harness.sdk, validator).unwrap()); + } + consensus_storage() + .tombstoned_accessor() + .entry(tombstoned) + .set_checked(&mut harness.sdk, true) + .unwrap(); + staking_storage() + .validators_accessor() + .entry(demoted) + .status_accessor() + .set_checked(&mut harness.sdk, STATUS_PENDING) + .unwrap(); + harness.sdk.take_logs(); + + for validator in [tombstoned, demoted] { + staking::release_production_exclusion(&mut harness.sdk, validator).unwrap(); + assert!( + !staking::selection_visible_at(&harness.sdk, validator, 1).unwrap(), + "a blind re-stamp would re-seat a validator the selection filter has no other check for" + ); + } + assert!( + harness.sdk.take_logs().is_empty(), + "a skipped release must not announce one" + ); + + staking::release_production_exclusion(&mut harness.sdk, healthy).unwrap(); + assert!(staking::selection_visible_at(&harness.sdk, healthy, 1).unwrap()); + let logs = harness.sdk.take_logs(); + let (data, topics) = logs + .iter() + .find(|(_, topics)| { + topics.first() == Some(&B256::new(events::ProductionExclusionReleased::SELECTOR)) + }) + .expect("release event"); + assert_eq!(&topics[1].0[12..], healthy.as_slice()); + assert_eq!(decode_output::(data), 1); +} + +#[test] +fn governance_activation_does_not_cancel_a_running_exclusion() { + let owner = Address::with_last_byte(0xa0); + let keeper = Address::with_last_byte(0x01); + let subject = Address::with_last_byte(0x02); + let mut harness = Harness::new(1_000); + assert_eq!( + harness.initialize( + owner, + vec![keeper, subject], + vec![ + DEFAULT_MIN_VALIDATOR_STAKE * U256::from(2), + DEFAULT_MIN_VALIDATOR_STAKE, + ], + 0, + ), + ExitCode::Ok + ); + + let readmit = production_liveness_storage() + .validators_accessor() + .entry(subject) + .readmit_at_epoch_accessor(); + harness.set_caller(GENESIS_GOVERNANCE); + assert_eq!( + harness + .call(encode_call( + SIG_DISABLE_VALIDATOR, + &AddressCommand { value: subject }, + )) + .0, + ExitCode::Ok + ); + readmit.set_checked(&mut harness.sdk, 5).unwrap(); + assert_eq!( + harness + .call(encode_call( + SIG_ACTIVATE_VALIDATOR, + &AddressCommand { value: subject }, + )) + .0, + ExitCode::Ok + ); + assert_eq!( + staking_storage() + .validators_accessor() + .entry(subject) + .status_accessor() + .get_checked(&harness.sdk) + .unwrap(), + STATUS_ACTIVE + ); + assert!( + !staking::selection_visible_at(&harness.sdk, subject, 1).unwrap(), + "re-activation must not re-stamp visibility while an exclusion is running" + ); + assert_eq!( + staking::selected_validators_at(&harness.sdk, 1).unwrap(), + vec![keeper] + ); + + assert_eq!( + harness + .call(encode_call( + SIG_DISABLE_VALIDATOR, + &AddressCommand { value: subject }, + )) + .0, + ExitCode::Ok + ); + readmit.set_checked(&mut harness.sdk, 0).unwrap(); + assert_eq!( + harness + .call(encode_call( + SIG_ACTIVATE_VALIDATOR, + &AddressCommand { value: subject }, + )) + .0, + ExitCode::Ok + ); + assert!( + staking::selection_visible_at(&harness.sdk, subject, 1).unwrap(), + "with no exclusion recorded the activation stamp is unchanged" + ); +} + +#[test] +fn production_liveness_views_read_the_new_namespace() { + let owner = Address::with_last_byte(0xa0); + let validator = Address::with_last_byte(0x01); + let stranger = Address::with_last_byte(0x0f); + let mut harness = Harness::new(1_000); + assert_eq!( + harness.initialize( + owner, + vec![validator], + vec![DEFAULT_MIN_VALIDATOR_STAKE], + 0 + ), + ExitCode::Ok + ); + commit_test_committee(&mut harness.sdk, 4, &[(validator, DEFAULT_MIN_VALIDATOR_STAKE)]); + + let storage = production_liveness_storage(); + storage + .last_processed_block_accessor() + .set_checked(&mut harness.sdk, 1_234) + .unwrap(); + storage + .blocks_in_epoch_accessor() + .entry(4u64) + .set_checked(&mut harness.sdk, 200) + .unwrap(); + storage + .produced_accessor() + .entry(4u64) + .entry(0u32) + .set_checked(&mut harness.sdk, 7) + .unwrap(); + storage + .pending_exclusions_accessor() + .push_checked(&mut harness.sdk, validator) + .unwrap(); + let record = storage.validators_accessor().entry(validator); + record + .total_produced_accessor() + .set_checked(&mut harness.sdk, 99) + .unwrap(); + record + .last_produced_epoch_p1_accessor() + .set_checked(&mut harness.sdk, 5) + .unwrap(); + record + .last_failed_epoch_p1_accessor() + .set_checked(&mut harness.sdk, 3) + .unwrap(); + record + .readmit_at_epoch_accessor() + .set_checked(&mut harness.sdk, 9) + .unwrap(); + record + .kick_count_accessor() + .set_checked(&mut harness.sdk, 2) + .unwrap(); + + let (exit, output) = harness.call(encode_empty_call(SIG_LAST_PROCESSED_BLOCK)); + assert_eq!(exit, ExitCode::Ok); + assert_eq!(decode_output::(&output), 1_234); + + let (exit, output) = harness.call(encode_call(SIG_BLOCKS_IN_EPOCH, &U64Command { value: 4 })); + assert_eq!(exit, ExitCode::Ok); + assert_eq!(decode_output::(&output), 200); + + let (exit, output) = harness.call(encode_call( + SIG_PRODUCED_AT, + &EpochSignerCommand { + epoch: 4, + signer_idx: 0, + }, + )); + assert_eq!(exit, ExitCode::Ok); + assert_eq!(decode_output::(&output), 7); + + let (exit, output) = harness.call(encode_empty_call(SIG_PENDING_EXCLUSIONS)); + assert_eq!(exit, ExitCode::Ok); + assert_eq!(decode_output::>(&output), vec![validator]); + + let (exit, output) = harness.call(encode_call( + SIG_READMIT_AT_EPOCH, + &AddressCommand { value: validator }, + )); + assert_eq!(exit, ExitCode::Ok); + assert_eq!(decode_output::(&output), 9); + + let (exit, output) = harness.call(encode_call( + SIG_GET_PRODUCTION_STATS, + &ValidatorEpochCommand { + validator, + before_epoch: 4, + }, + )); + assert_eq!(exit, ExitCode::Ok); + assert_eq!( + decode_output::<(u32, u64, u64, u64, u32, u64)>(&output), + (7, 99, 4, 2, 2, 9) + ); + + let (exit, output) = harness.call(encode_call( + SIG_GET_PRODUCTION_STATS, + &ValidatorEpochCommand { + validator: stranger, + before_epoch: 4, + }, + )); + assert_eq!(exit, ExitCode::Ok); + assert_eq!( + decode_output::<(u32, u64, u64, u64, u32, u64)>(&output), + (0, 0, 0, 0, 0, 0), + "a non-member has no committee index and must not alias index 0" + ); +} + +/// Boots the tier with one active validator per stake, the committee cap set to +/// `cap`, the kill switch off and the system caller installed. +fn liveness_harness(stakes: &[U256], cap: u32) -> (Harness, Vec
) { + let owner = Address::with_last_byte(0xa0); + let members: Vec
= (1..=stakes.len()) + .map(|index| Address::with_last_byte(index as u8)) + .collect(); + let mut harness = Harness::new(1_000); + let mut command = harness.initialize_command(owner, members.clone(), stakes.to_vec(), 0); + command.active_validators_length = cap; + assert_eq!(harness.initialize_with(command), ExitCode::Ok); + harness.set_caller(GENESIS_GOVERNANCE); + assert_eq!( + harness + .call(encode_call( + SIG_SET_PRODUCTION_LIVENESS_DISABLED, + &BoolCommand { value: false }, + )) + .0, + ExitCode::Ok + ); + harness.set_caller(SYSTEM_CALLER); + harness.sdk.take_logs(); + (harness, members) +} + +fn record_production(harness: &mut Harness, block_number: u64, leader_index: u8) -> ExitCode { + harness.set_caller(SYSTEM_CALLER); + harness + .call(encode_call( + SIG_RECORD_PRODUCTION, + &RecordProductionCommand { + block_number, + leader_index, + }, + )) + .0 +} + +fn seed_epoch_production( + sdk: &mut TestingContextImpl, + epoch: u64, + produced: &[u32], + recorded: u32, +) { + let storage = production_liveness_storage(); + for (index, count) in produced.iter().enumerate() { + storage + .produced_accessor() + .entry(epoch) + .entry(index as u32) + .set_checked(sdk, *count) + .unwrap(); + } + storage + .blocks_in_epoch_accessor() + .entry(epoch) + .set_checked(sdk, recorded) + .unwrap(); +} + +/// Drives the close of `epoch` by recording the first block of `epoch + 1`. +fn close_epoch_via_record(harness: &mut Harness, epoch: u64) -> ExitCode { + let boundary = 1_000 + (epoch + 1) * DEFAULT_EPOCH_BLOCK_INTERVAL; + production_liveness_storage() + .last_processed_block_accessor() + .set_checked(&mut harness.sdk, boundary - 1) + .unwrap(); + harness.set_block_number(boundary); + record_production(harness, boundary, 0) +} + +fn production_record( + sdk: &TestingContextImpl, + validator: Address, +) -> (u64, u64, u64, u64, u32) { + let record = production_liveness_storage() + .validators_accessor() + .entry(validator); + ( + record.total_produced_accessor().get_checked(sdk).unwrap(), + record + .last_produced_epoch_p1_accessor() + .get_checked(sdk) + .unwrap(), + record + .last_failed_epoch_p1_accessor() + .get_checked(sdk) + .unwrap(), + record.readmit_at_epoch_accessor().get_checked(sdk).unwrap(), + record.kick_count_accessor().get_checked(sdk).unwrap(), + ) +} + +fn pending_exclusion_set(sdk: &TestingContextImpl) -> Vec
{ + let entries = production_liveness_storage().pending_exclusions_accessor(); + (0..entries.len_checked(sdk).unwrap()) + .map(|index| entries.at(index).get_checked(sdk).unwrap()) + .collect() +} + +fn logs_of(logs: &[(Bytes, Vec)], selector: [u8; 32]) -> Vec<(Bytes, Vec)> { + logs.iter() + .filter(|(_, topics)| topics.first() == Some(&B256::new(selector))) + .cloned() + .collect() +} + +// A repeated block number must not be counted twice, and the epoch cursor must +// come from the block already stored rather than from the one arriving: reading +// it after the overwrite collapses `previous_epoch` onto `epoch` and the close +// never runs. +#[test] +fn record_production_belt_holds_and_the_epoch_cursor_precedes_the_overwrite() { + let (mut harness, members) = liveness_harness(&[DEFAULT_MIN_VALIDATOR_STAKE], 21); + commit_test_committee( + &mut harness.sdk, + 0, + &[(members[0], DEFAULT_MIN_VALIDATOR_STAKE)], + ); + + assert_eq!(record_production(&mut harness, 1_000, 0), ExitCode::Ok); + assert_eq!(record_production(&mut harness, 1_000, 0), ExitCode::Ok); + assert_eq!(record_production(&mut harness, 999, 0), ExitCode::Ok); + + let storage = production_liveness_storage(); + assert_eq!( + storage + .blocks_in_epoch_accessor() + .entry(0u64) + .get_checked(&harness.sdk) + .unwrap(), + 1, + "a replayed block number must be counted exactly once" + ); + assert_eq!(production_record(&harness.sdk, members[0]).0, 1); + assert_eq!( + storage + .last_processed_block_accessor() + .get_checked(&harness.sdk) + .unwrap(), + 1_000 + ); + + harness.set_block_number(1_200); + harness.sdk.take_logs(); + assert_eq!(record_production(&mut harness, 1_200, 0), ExitCode::Ok); + let logs = harness.sdk.take_logs(); + let partial = logs_of(&logs, events::PartialEpoch::SELECTOR); + assert_eq!( + partial.len(), + 1, + "crossing into epoch 1 must close epoch 0, which the stored cursor identifies" + ); + assert_eq!( + SolidityABI::::decode(&partial[0].1[1].as_slice(), 0).unwrap(), + 0 + ); + assert_eq!( + decode_output::<(u32, u32)>(&partial[0].0), + (1, DEFAULT_EPOCH_BLOCK_INTERVAL as u32) + ); +} + +// The close names the epoch that ended, and the block that triggered it belongs +// to the epoch that started: closing `epoch` instead of `previous_epoch` reports +// an epoch nothing has recorded yet. +#[test] +fn the_close_reports_the_epoch_that_ended_not_the_one_the_block_starts() { + let (mut harness, members) = liveness_harness(&[DEFAULT_MIN_VALIDATOR_STAKE], 21); + commit_test_committee( + &mut harness.sdk, + 0, + &[(members[0], DEFAULT_MIN_VALIDATOR_STAKE)], + ); + commit_test_committee( + &mut harness.sdk, + 1, + &[(members[0], DEFAULT_MIN_VALIDATOR_STAKE)], + ); + seed_epoch_production(&mut harness.sdk, 0, &[7], 7); + + assert_eq!(close_epoch_via_record(&mut harness, 0), ExitCode::Ok); + + let storage = production_liveness_storage(); + assert_eq!( + storage + .blocks_in_epoch_accessor() + .entry(0u64) + .get_checked(&harness.sdk) + .unwrap(), + 7, + "the boundary block must not land in the epoch being closed" + ); + assert_eq!( + storage + .blocks_in_epoch_accessor() + .entry(1u64) + .get_checked(&harness.sdk) + .unwrap(), + 1 + ); + let logs = harness.sdk.take_logs(); + let partial = logs_of(&logs, events::PartialEpoch::SELECTOR); + assert_eq!(partial.len(), 1); + assert_eq!( + SolidityABI::::decode(&partial[0].1[1].as_slice(), 0).unwrap(), + 0 + ); + assert_eq!( + decode_output::<(u32, u32)>(&partial[0].0).0, + 7, + "the taint must be measured before the boundary block is credited" + ); +} + +// An uncommitted epoch parks the block: nothing counted, nothing credited, and +// no revert — a revert here is a per-block system call failing, i.e. a halt. +#[test] +fn an_uncommitted_committee_parks_the_block_instead_of_reverting() { + let (mut harness, members) = liveness_harness(&[DEFAULT_MIN_VALIDATOR_STAKE], 21); + + assert_eq!(record_production(&mut harness, 1_000, 0), ExitCode::Ok); + let storage = production_liveness_storage(); + assert_eq!( + storage + .last_processed_block_accessor() + .get_checked(&harness.sdk) + .unwrap(), + 1_000, + "the idempotency belt still advances over a parked block" + ); + assert_eq!( + storage + .blocks_in_epoch_accessor() + .entry(0u64) + .get_checked(&harness.sdk) + .unwrap(), + 0 + ); + assert_eq!( + storage + .produced_accessor() + .entry(0u64) + .entry(0u32) + .get_checked(&harness.sdk) + .unwrap(), + 0 + ); + assert_eq!(production_record(&harness.sdk, members[0]), (0, 0, 0, 0, 0)); + + harness.set_block_number(1_200); + harness.sdk.take_logs(); + assert_eq!(record_production(&mut harness, 1_200, 0), ExitCode::Ok); + let logs = harness.sdk.take_logs(); + assert_eq!( + decode_output::<(u32, u32)>(&logs_of(&logs, events::PartialEpoch::SELECTOR)[0].0), + (0, DEFAULT_EPOCH_BLOCK_INTERVAL as u32), + "a fully parked epoch is tainted rather than silently complete" + ); + assert!( + logs_of(&logs, events::EpochBlendRewardsCommitted::SELECTOR).is_empty(), + "an epoch with no recorded block must not draw a pot" + ); +} + +fn set_min_verdict_due_blocks(harness: &mut Harness, value: u32) { + harness.set_caller(GENESIS_GOVERNANCE); + assert_eq!( + harness + .call(encode_call( + SIG_SET_MIN_VERDICT_DUE_BLOCKS, + &U32Command { value }, + )) + .0, + ExitCode::Ok + ); + harness.set_caller(SYSTEM_CALLER); +} + +fn equal_weight_committee(sdk: &mut TestingContextImpl, epoch: u64, members: &[Address]) { + let weights: Vec<(Address, U256)> = members + .iter() + .map(|member| (*member, DEFAULT_MIN_VALIDATOR_STAKE)) + .collect(); + commit_test_committee(sdk, epoch, &weights); +} + +// The taint is derived from the block count, so the identical verdict input +// judges nobody at 199 blocks and judges normally at 200. Anything that drops a +// record therefore disables the tier for a whole epoch while it reads as on. +#[test] +fn a_partial_epoch_suppresses_judging_entirely() { + let (mut harness, members) = + liveness_harness(&[DEFAULT_MIN_VALIDATOR_STAKE * U256::from(2); 4], 2); + set_min_verdict_due_blocks(&mut harness, 10); + equal_weight_committee(&mut harness.sdk, 0, &members); + equal_weight_committee(&mut harness.sdk, 1, &members); + + let produced = [100, 100, 0, 0]; + seed_epoch_production(&mut harness.sdk, 0, &produced, 199); + harness.sdk.take_logs(); + assert_eq!(close_epoch_via_record(&mut harness, 0), ExitCode::Ok); + + let logs = harness.sdk.take_logs(); + assert_eq!(logs_of(&logs, events::PartialEpoch::SELECTOR).len(), 1); + assert!( + logs_of(&logs, events::ProductionVerdictFailed::SELECTOR).is_empty(), + "one missing record must cost the whole epoch its verdicts" + ); + for member in &members { + assert_eq!(production_record(&harness.sdk, *member).2, 0); + } + assert!(pending_exclusion_set(&harness.sdk).is_empty()); + + seed_epoch_production(&mut harness.sdk, 1, &produced, 200); + harness.sdk.take_logs(); + assert_eq!(close_epoch_via_record(&mut harness, 1), ExitCode::Ok); + let logs = harness.sdk.take_logs(); + assert!(logs_of(&logs, events::PartialEpoch::SELECTOR).is_empty()); + assert_eq!( + logs_of(&logs, events::ProductionVerdictFailed::SELECTOR).len(), + 2, + "the same production judges normally once the epoch is complete" + ); +} + +// Both predicates are cross-multiplied against the frozen weights: a member +// whose due share falls under the floor holds no verdict at all, and a member +// producing exactly half its due passes. +// The kill switch has two halves. Freezing releases is covered elsewhere; this +// covers the other half, which is only reachable on a COMPLETE epoch — a partial +// one takes the taint arm and never reaches judging at all. +#[test] +fn the_kill_switch_also_suppresses_verdicts() { + let token = DEFAULT_MIN_VALIDATOR_STAKE; + let (mut harness, members) = liveness_harness(&[token * U256::from(50); 4], 2); + set_min_verdict_due_blocks(&mut harness, 10); + commit_test_committee( + &mut harness.sdk, + 0, + &[ + (members[0], token * U256::from(49)), + (members[1], token * U256::from(25)), + (members[2], token * U256::from(25)), + (members[3], token), + ], + ); + seed_epoch_production(&mut harness.sdk, 0, &[151, 24, 25, 0], 200); + + harness.set_caller(GENESIS_GOVERNANCE); + assert_eq!( + harness + .call(encode_call( + SIG_SET_PRODUCTION_LIVENESS_DISABLED, + &BoolCommand { value: true }, + )) + .0, + ExitCode::Ok + ); + harness.sdk.take_logs(); + + assert_eq!(close_epoch_via_record(&mut harness, 0), ExitCode::Ok); + + let logs = harness.sdk.take_logs(); + assert!( + logs_of(&logs, events::ProductionVerdictFailed::SELECTOR).is_empty(), + "a complete epoch must still produce no verdict while the tier is off" + ); + assert_eq!( + production_record(&harness.sdk, members[1]).2, + 0, + "and no failure is recorded against the member that would have failed" + ); +} + +#[test] +fn verdicts_come_from_the_frozen_weights_and_are_never_divided() { + let token = DEFAULT_MIN_VALIDATOR_STAKE; + let (mut harness, members) = liveness_harness(&[token * U256::from(50); 4], 2); + set_min_verdict_due_blocks(&mut harness, 10); + commit_test_committee( + &mut harness.sdk, + 0, + &[ + (members[0], token * U256::from(49)), + (members[1], token * U256::from(25)), + (members[2], token * U256::from(25)), + (members[3], token), + ], + ); + // due = 98 / 50 / 50 / 2 blocks out of 200 recorded, floor 10. + seed_epoch_production(&mut harness.sdk, 0, &[151, 24, 25, 0], 200); + harness.sdk.take_logs(); + + assert_eq!(close_epoch_via_record(&mut harness, 0), ExitCode::Ok); + + let logs = harness.sdk.take_logs(); + let failed = logs_of(&logs, events::ProductionVerdictFailed::SELECTOR); + assert_eq!(failed.len(), 1, "exactly one member is under half its due"); + assert_eq!(&failed[0].1[2].0[12..], members[1].as_slice()); + assert_eq!( + decode_output::<(u32, U256)>(&failed[0].0), + (24, U256::from(50)) + ); + assert_eq!(production_record(&harness.sdk, members[1]).2, 1); + assert_eq!( + production_record(&harness.sdk, members[2]).2, + 0, + "producing exactly half the due share passes" + ); + assert_eq!( + production_record(&harness.sdk, members[3]).2, + 0, + "a member due fewer blocks than the floor holds no verdict at zero production" + ); + assert_eq!(pending_exclusion_set(&harness.sdk), vec![members[1]]); +} + +// More than `f` FIRST-TIME failures in one epoch reads as an environment and +// stamps nobody; the same members failing again are no longer new, so the tier +// answers them. At epoch 0 the never-failed sentinel and "failed epoch −1" +// collide, and a bare equality reads a first-ever failure as a repeat. +#[test] +fn the_correlation_guard_keys_on_new_failures_and_frees_the_next_epoch() { + let (mut harness, members) = + liveness_harness(&[DEFAULT_MIN_VALIDATOR_STAKE * U256::from(2); 7], 2); + set_min_verdict_due_blocks(&mut harness, 10); + for epoch in 0..2 { + equal_weight_committee(&mut harness.sdk, epoch, &members); + } + let produced = [50, 50, 50, 50, 0, 0, 0]; + + seed_epoch_production(&mut harness.sdk, 0, &produced, 200); + harness.sdk.take_logs(); + assert_eq!(close_epoch_via_record(&mut harness, 0), ExitCode::Ok); + + let logs = harness.sdk.take_logs(); + let correlated = logs_of(&logs, events::CorrelatedFailureEpoch::SELECTOR); + assert_eq!(correlated.len(), 1); + assert_eq!( + decode_output::<(U256, U256)>(&correlated[0].0), + (U256::from(3), U256::from(2)) + ); + assert!( + pending_exclusion_set(&harness.sdk).is_empty(), + "an environment is not answered by excluding its victims" + ); + for member in &members[4..] { + let record = production_record(&harness.sdk, *member); + assert_eq!(record.2, 1, "the failure bit is written on the guarded path"); + assert_eq!(record.4, 0); + } + + seed_epoch_production(&mut harness.sdk, 1, &produced, 200); + harness.sdk.take_logs(); + assert_eq!(close_epoch_via_record(&mut harness, 1), ExitCode::Ok); + + let logs = harness.sdk.take_logs(); + assert!( + logs_of(&logs, events::CorrelatedFailureEpoch::SELECTOR).is_empty(), + "a repeat failure is evidence of itself, not of an environment" + ); + assert_eq!( + pending_exclusion_set(&harness.sdk), + vec![members[4], members[5]] + ); } +// Two stamps per close at most, and never more than `f` concurrent. #[test] -fn continuously_seated_validator_self_stake_unlocks_at_bounded_liability_deadline() { - let owner = Address::with_last_byte(0xa0); - let validator = Address::with_last_byte(0x01); - let token = Address::with_last_byte(0xc0); - let withdrawn = DEFAULT_MIN_VALIDATOR_STAKE; - let stake = withdrawn * U256::from(2); - let activation_block = 1_000; - let mut harness = Harness::new(activation_block); - harness.set_caller(owner); - assert_eq!( - harness.initialize(owner, vec![validator], vec![stake], 500), - ExitCode::Ok - ); - chain_config_storage() - .staking_token_accessor() - .set_checked(&mut harness.sdk, token) - .unwrap(); +fn stamps_are_bounded_per_close_and_by_the_concurrent_budget() { + let (mut harness, members) = + liveness_harness(&[DEFAULT_MIN_VALIDATOR_STAKE * U256::from(2); 10], 2); + set_min_verdict_due_blocks(&mut harness, 10); + for epoch in 0..3 { + equal_weight_committee(&mut harness.sdk, epoch, &members); + } + // A ladder already four episodes deep, so a stamp outlives the next close + // and the concurrent budget is what stops the third one. + for member in &members { + production_liveness_storage() + .validators_accessor() + .entry(*member) + .kick_count_accessor() + .set_checked(&mut harness.sdk, 4) + .unwrap(); + } - harness.set_caller(SYSTEM_CALLER); + seed_epoch_production( + &mut harness.sdk, + 0, + &[29, 29, 29, 29, 28, 28, 28, 0, 0, 0], + 200, + ); + assert_eq!(close_epoch_via_record(&mut harness, 0), ExitCode::Ok); assert_eq!( - harness - .call(encode_args_call( - SIG_COMMIT_EPOCH_COMMITTEE, - &(vec![validator],), - )) - .0, - ExitCode::Ok + pending_exclusion_set(&harness.sdk), + vec![members[7], members[8]], + "three failers, two stamps: the per-close cap, ordered by address" ); - staking::undelegate_from(&mut harness.sdk, validator, validator, withdrawn).unwrap(); - let delegation = staking_storage() - .validator_delegations_accessor() - .entry(validator) - .entry(validator); - let undelegates = delegation.undelegate_queue_accessor(); - let maturity_epoch = DEFAULT_UNDELEGATE_PERIOD + 1; - let liability_end_epoch = - maturity_epoch + EPOCH_COMMITTEE_RETENTION_MARGIN + MAX_COMMITTEE_LOOKAHEAD_EPOCHS; - assert_eq!( - undelegates - .at(0) - .epoch_accessor() - .get_checked(&harness.sdk) - .unwrap(), - maturity_epoch + seed_epoch_production( + &mut harness.sdk, + 1, + &[34, 33, 33, 33, 33, 34, 0, 0, 0, 0], + 200, ); + assert_eq!(close_epoch_via_record(&mut harness, 1), ExitCode::Ok); assert_eq!( - undelegates - .at(0) - .self_stake_unlock_epoch_accessor() - .get_checked(&harness.sdk) - .unwrap(), - liability_end_epoch + pending_exclusion_set(&harness.sdk), + vec![members[7], members[8], members[6]], + "four failers, one stamp: `f` concurrent exclusions is the ceiling" ); - assert_eq!( - staking_storage() - .validators_accessor() - .entry(validator) - .self_stake_unlock_epoch_accessor() - .get_checked(&harness.sdk) - .unwrap(), - liability_end_epoch + assert_eq!(production_record(&harness.sdk, members[9]).3, 0); + + seed_epoch_production( + &mut harness.sdk, + 2, + &[34, 33, 33, 33, 33, 34, 0, 0, 0, 0], + 200, ); + assert_eq!(close_epoch_via_record(&mut harness, 2), ExitCode::Ok); + assert_eq!(pending_exclusion_set(&harness.sdk).len(), 3); +} - let transfers = Rc::new(RefCell::new(Vec::<(Address, U256)>::new())); - let recorded = transfers.clone(); - harness - .sdk - .set_call_handler(move |address, _value, input, _fuel_limit| { - assert_eq!(address, token); - assert_eq!( - u32::from_be_bytes(input[..SIG_LEN_BYTES].try_into().unwrap()), - SIG_ERC20_TRANSFER - ); - let transfer = - SolidityABI::<(Address, U256)>::decode(&&input[SIG_LEN_BYTES..], 0).unwrap(); - recorded.borrow_mut().push(transfer); - SyscallResult::new(Bytes::new(), 0, 0, ExitCode::Ok) - }); +// Releases are frozen with the rest of the verdict state under the kill switch, +// so no exclusion expires unnoticed while the tier is off. +#[test] +fn the_kill_switch_freezes_releases() { + let (mut harness, members) = + liveness_harness(&[DEFAULT_MIN_VALIDATOR_STAKE * U256::from(2); 3], 1); + let excluded = members[2]; + assert!(staking::apply_production_exclusion(&mut harness.sdk, excluded).unwrap()); + let record = production_liveness_storage() + .validators_accessor() + .entry(excluded); + record + .readmit_at_epoch_accessor() + .set_checked(&mut harness.sdk, 1) + .unwrap(); + production_liveness_storage() + .pending_exclusions_accessor() + .push_checked(&mut harness.sdk, excluded) + .unwrap(); - // Keep committing this still-active validator after its partial self-exit. - // New committees are secured by the remaining self-stake and must not keep - // extending the already-queued principal's fixed liability deadline. - harness.set_caller(SYSTEM_CALLER); + harness.set_caller(GENESIS_GOVERNANCE); assert_eq!( harness - .call(encode_args_call( - SIG_COMMIT_EPOCH_COMMITTEE, - &(vec![validator],), + .call(encode_call( + SIG_SET_PRODUCTION_LIVENESS_DISABLED, + &BoolCommand { value: true }, )) .0, ExitCode::Ok ); + harness.sdk.take_logs(); + assert_eq!(close_epoch_via_record(&mut harness, 1), ExitCode::Ok); + + assert_eq!(pending_exclusion_set(&harness.sdk), vec![excluded]); + assert_eq!(record.readmit_at_epoch_accessor().get_checked(&harness.sdk).unwrap(), 1); + assert!(!staking::selection_visible_at(&harness.sdk, excluded, 3).unwrap()); + assert!(harness + .sdk + .take_logs() + .iter() + .all(|(_, topics)| topics.first() + != Some(&B256::new(events::ProductionExclusionReleased::SELECTOR)))); + + harness.set_caller(GENESIS_GOVERNANCE); assert_eq!( harness - .call(encode_args_call( - SIG_COMMIT_EPOCH_COMMITTEE, - &(vec![validator],), + .call(encode_call( + SIG_SET_PRODUCTION_LIVENESS_DISABLED, + &BoolCommand { value: false }, )) .0, ExitCode::Ok ); - for epoch in 1..liability_end_epoch { - harness.set_block_number(activation_block + DEFAULT_EPOCH_BLOCK_INTERVAL * epoch); - harness.set_caller(SYSTEM_CALLER); - assert_eq!( - harness - .call(encode_args_call( - SIG_COMMIT_EPOCH_COMMITTEE, - &(vec![validator],), - )) - .0, - ExitCode::Ok - ); + harness.sdk.take_logs(); + assert_eq!(close_epoch_via_record(&mut harness, 2), ExitCode::Ok); - if epoch == maturity_epoch { - harness.set_caller(validator); - let (exit, output) = harness.call(encode_call( - SIG_GET_VALIDATOR_SELF_STAKE_LOCK, - &AddressCommand { value: validator }, - )); - assert_eq!(exit, ExitCode::Ok); - assert_eq!( - decode_output::<(bool, u64)>(&output), - (true, liability_end_epoch) - ); - consensus::ensure_equivocation_evidence_unexpired(&mut harness.sdk, 2).unwrap(); - assert_eq!( - harness - .call(encode_call( - SIG_CLAIM_DELEGATOR_FEE, - &AddressCommand { value: validator }, - )) - .0, - ExitCode::Ok - ); - assert!(transfers.borrow().is_empty()); - assert_eq!( - delegation - .undelegate_gap_accessor() - .get_checked(&harness.sdk) - .unwrap(), - 0 - ); + assert!(pending_exclusion_set(&harness.sdk).is_empty()); + assert_eq!(record.readmit_at_epoch_accessor().get_checked(&harness.sdk).unwrap(), 0); + assert!(staking::selection_visible_at(&harness.sdk, excluded, 4).unwrap()); +} + +struct CloseCallState { + reserve_balances: VecDeque>, + disbursed: Vec, + self_call_fuel: Option, + self_calls: usize, +} + +fn reserve_reply(input: &[u8], state: &Rc>) -> SyscallResult { + let selector = u32::from_be_bytes(input[..SIG_LEN_BYTES].try_into().unwrap()); + match selector { + SIG_RESERVE_BALANCE => match state.borrow_mut().reserve_balances.pop_front() { + Some(Some(balance)) => { + SyscallResult::new(encode_mock_return(&balance), 0, 0, ExitCode::Ok) + } + _ => SyscallResult::new(Bytes::new(), 0, 0, ExitCode::Panic), + }, + SIG_RESERVE_DISBURSE => { + let params = &input[SIG_LEN_BYTES..]; + let (_, amount) = SolidityABI::<(Address, U256)>::decode(¶ms, 0).unwrap(); + state.borrow_mut().disbursed.push(amount); + SyscallResult::new(encode_mock_return(&amount), 0, 0, ExitCode::Ok) } + _ => SyscallResult::new(Bytes::new(), 0, 0, ExitCode::Panic), } +} + +/// Mocks the stipend leg's fuel-capped self-call. +/// +/// Everything about the nested frame is real — same storage, same dispatch, +/// caller rewritten to the contract itself, failure returned as a status. Only +/// the journal is emulated: this host has no checkpoint, so storage is +/// snapshotted before the re-entry and restored when the frame fails, which is +/// what `checkpoint_revert` does on the real one. +fn install_close_call_handler(harness: &Harness, state: Rc>) { + let host = harness.sdk.clone(); + harness + .sdk + .set_call_handler(move |address, _value, input, fuel_limit| { + if address != GENESIS_STAKING { + return reserve_reply(input, &state); + } + { + let mut observed = state.borrow_mut(); + observed.self_calls += 1; + observed.self_call_fuel = fuel_limit; + } + let snapshot = host.dump_storage(); + let outer_caller = host.context().contract_caller(); + host.context_mut().caller = GENESIS_STAKING; + let mut nested = host.clone().with_input(Bytes::from(input.to_vec())); + let inner = state.clone(); + nested.set_call_handler(move |_address, _value, input, _fuel| { + reserve_reply(input, &inner) + }); + let outcome = main_entry(&mut nested); + nested.context_mut().caller = outer_caller; + let data = Bytes::from(nested.take_output()); + match outcome { + Ok(()) => SyscallResult::new(data, 0, 0, ExitCode::Ok), + Err(status) => { + nested.restore_storage(snapshot); + SyscallResult::new(data, 0, 0, status) + } + } + }); +} + +// The tolerant leg. A stipend that dies mid-catch-up discards its own frame and +// nothing else: the release and the verdict of the same close survive, the +// reward cursor does not advance, and the outer frame reports the failure with +// an event of its own — a log written inside the discarded frame would go with +// it, and a system call leaves no receipt to read instead. +#[test] +fn a_failing_stipend_leg_leaves_the_releases_and_verdicts_of_its_close_intact() { + let token = DEFAULT_MIN_VALIDATOR_STAKE; + let (mut harness, members) = liveness_harness(&[token * U256::from(2); 4], 2); + set_min_verdict_due_blocks(&mut harness, 10); + equal_weight_committee(&mut harness.sdk, 0, &members); + equal_weight_committee(&mut harness.sdk, 1, &members); + + let reserve = Address::with_last_byte(0xc0); + let config = chain_config_storage(); + config + .blend_stipend_per_epoch_accessor() + .set_checked(&mut harness.sdk, U256::from(400)) + .unwrap(); + config + .blend_reserve_accessor() + .set_checked(&mut harness.sdk, reserve) + .unwrap(); + + // Leg 1 has an exclusion expiring at this close. + let releasing = members[3]; + assert!(staking::apply_production_exclusion(&mut harness.sdk, releasing).unwrap()); + let releasing_record = production_liveness_storage() + .validators_accessor() + .entry(releasing); + releasing_record + .readmit_at_epoch_accessor() + .set_checked(&mut harness.sdk, 2) + .unwrap(); + releasing_record + .last_failed_epoch_p1_accessor() + .set_checked(&mut harness.sdk, 1) + .unwrap(); + production_liveness_storage() + .pending_exclusions_accessor() + .push_checked(&mut harness.sdk, releasing) + .unwrap(); + + // Leg 2 has one member under half its due. + seed_epoch_production(&mut harness.sdk, 0, &[50, 50, 50, 50], 200); + seed_epoch_production(&mut harness.sdk, 1, &[67, 67, 0, 66], 200); + + let state = Rc::new(RefCell::new(CloseCallState { + // Epoch 0 settles; epoch 1 dies inside the same self-call, so the + // discarded frame is one that had already written. + reserve_balances: vec![Some(U256::from(1_000)), None].into(), + disbursed: Vec::new(), + self_call_fuel: None, + self_calls: 0, + })); + install_close_call_handler(&harness, state.clone()); + harness.sdk.take_logs(); - harness.set_block_number(activation_block + DEFAULT_EPOCH_BLOCK_INTERVAL * liability_end_epoch); assert_eq!( - staking_storage() - .validators_accessor() - .entry(validator) - .status_accessor() - .get_checked(&harness.sdk) - .unwrap(), - STATUS_ACTIVE + close_epoch_via_record(&mut harness, 1), + ExitCode::Ok, + "a failing stipend must not take the per-block system call down with it" ); - assert!( - staking::selected_validators_at(&harness.sdk, liability_end_epoch) - .unwrap() - .contains(&validator) + + assert_eq!(state.borrow().self_calls, 1); + assert_eq!( + state.borrow().self_call_fuel, + Some(12_000_000 * 20), + "the cap is a fuel figure, not the Solidity gas figure" ); assert_eq!( - consensus_storage() - .epoch_committees_accessor() - .entry(2) - .len_checked(&harness.sdk) - .unwrap(), - 1, - "time-based release must not depend on a later committee commit pruning storage" + state.borrow().disbursed, + vec![U256::from(400)], + "the mock records the call; on the real runtime the reserve's state change is discarded with the frame for epoch 0" ); - harness.set_caller(validator); - let (exit, output) = harness.call(encode_call( - SIG_GET_VALIDATOR_SELF_STAKE_LOCK, - &AddressCommand { value: validator }, - )); - assert_eq!(exit, ExitCode::Ok); + + let logs = harness.sdk.take_logs(); + let skipped = logs_of(&logs, events::StipendLegSkipped::SELECTOR); + assert_eq!(skipped.len(), 1); assert_eq!( - decode_output::<(bool, u64)>(&output), - (false, liability_end_epoch) + SolidityABI::::decode(&skipped[0].1[1].as_slice(), 0).unwrap(), + 1 ); - assert_direct_revert( - consensus::ensure_equivocation_evidence_unexpired(&mut harness.sdk, 2), - &harness.sdk, - ERR_EQUIVOCATION_EVIDENCE_EXPIRED, + + assert_eq!( + production_record(&harness.sdk, releasing).3, + 0, + "leg 1's release survives the stipend failure" ); + assert!(staking::selection_visible_at(&harness.sdk, releasing, 3).unwrap()); assert_eq!( - harness - .call(encode_call( - SIG_CLAIM_DELEGATOR_FEE, - &AddressCommand { value: validator }, - )) - .0, - ExitCode::Ok + pending_exclusion_set(&harness.sdk), + vec![members[2]], + "leg 2's stamp survives it too" ); - assert_eq!(transfers.borrow().as_slice(), &[(validator, withdrawn)]); + let stamped = production_record(&harness.sdk, members[2]); + assert_eq!((stamped.2, stamped.3, stamped.4), (2, 3, 1)); + + let staking_state = staking_storage(); assert_eq!( - delegation - .pending_undelegated_accessor() + staking_state + .last_rewarded_epoch_p1_accessor() .get_checked(&harness.sdk) .unwrap(), - U256::ZERO + 0, + "the reward cursor did not advance, so the next close retries contiguously" ); assert_eq!( - delegation - .undelegate_gap_accessor() + staking_state + .credited_blend_accessor() .get_checked(&harness.sdk) .unwrap(), - 1 + U256::ZERO ); assert_eq!( - staking_storage() - .validators_accessor() - .entry(validator) - .self_stake_unlock_epoch_accessor() + staking_state + .validator_snapshots_accessor() + .entry(members[0]) + .entry(0u64) + .total_blend_rewards_accessor() .get_checked(&harness.sdk) .unwrap(), - 0 + crate::math::U96::ZERO, + "the epoch-0 credit was inside the discarded frame" ); } + +#[test] +fn the_stipend_re_entry_is_reachable_only_from_the_contract_itself() { + let (mut harness, _) = liveness_harness(&[DEFAULT_MIN_VALIDATOR_STAKE], 21); + let calldata = encode_call(SIG_SETTLE_EPOCH_STIPEND_FROM, &U64Command { value: 0 }); + harness.set_caller(SYSTEM_CALLER); + assert_revert_selector(harness.call(calldata.clone()), ERR_ONLY_SELF_CALL); + harness.set_caller(GENESIS_GOVERNANCE); + assert_revert_selector(harness.call(calldata.clone()), ERR_ONLY_SELF_CALL); + harness.set_caller(GENESIS_STAKING); + assert_eq!(harness.call(calldata).0, ExitCode::Ok); +} diff --git a/contracts/staking/src/types.rs b/contracts/staking/src/types.rs index f966f8504..ebc519a44 100644 --- a/contracts/staking/src/types.rs +++ b/contracts/staking/src/types.rs @@ -23,8 +23,6 @@ pub struct InitializeCommand { pub staking_token: Address, pub active_validators_length: u32, pub epoch_block_interval: u32, - pub felony_threshold: u32, - pub validator_jail_epoch_length: u32, pub undelegate_period: u32, pub min_validator_stake_amount: U256, pub min_staking_amount: U256, @@ -101,6 +99,11 @@ pub struct RegisterValidatorCommand { impl FunctionArgs for RegisterValidatorCommand {} +#[derive(Default, Debug, Codec)] +pub struct BoolCommand { + pub value: bool, +} + #[derive(Default, Debug, Codec)] pub struct U32Command { pub value: u32, @@ -116,11 +119,6 @@ pub struct U256Command { pub value: U256, } -#[derive(Default, Debug, Codec)] -pub struct BoolCommand { - pub value: bool, -} - #[derive(Default, Debug, Clone, PartialEq, Eq, Codec)] pub struct ConsensusKeys { pub bls_pubkey: Bytes, @@ -128,6 +126,12 @@ pub struct ConsensusKeys { pub activation_epoch: u64, } +#[derive(Default, Debug, Codec)] +pub struct RecordProductionCommand { + pub block_number: u64, + pub leader_index: u8, +} + #[derive(Default, Debug, Codec)] pub struct EpochSignerCommand { pub epoch: u64, diff --git a/e2e/src/staking.rs b/e2e/src/staking.rs index c5b6a4ce7..6a25829a0 100644 --- a/e2e/src/staking.rs +++ b/e2e/src/staking.rs @@ -1,7 +1,7 @@ use crate::EvmTestingContextWithGenesis; use alloy_sol_types::{sol, SolCall}; use fluentbase_sdk::{ - hex, + address, hex, universal_token::{ApproveCommand, BalanceOfCommand, InitialSettings, UniversalTokenCommand}, Address, Bytes, B256, GENESIS_GOVERNANCE, GENESIS_STAKING, U256, }; @@ -10,6 +10,7 @@ use fluentbase_testing::EvmTestingContext; const OWNER: Address = Address::repeat_byte(0x11); const VALIDATOR: Address = Address::repeat_byte(0x22); const TOKEN: U256 = U256::from_limbs([1_000_000_000_000_000_000, 0, 0, 0]); +const SYSTEM_CALLER: Address = address!("0xfffffffffffffffffffffffffffffffffffffffe"); sol! { struct ConsensusKeys { @@ -30,8 +31,6 @@ sol! { address stakingToken, uint32 activeValidatorsLength, uint32 epochBlockInterval, - uint32 felonyThreshold, - uint32 validatorJailEpochLength, uint32 undelegatePeriod, uint256 minValidatorStakeAmount, uint256 minStakingAmount, @@ -59,6 +58,10 @@ sol! { external view returns (uint256 delegatedAmount, uint64 atEpoch); + function recordProduction(uint64 blockNumber, uint8 leaderIndex) external; + function settleEpochStipendFrom(uint64 epoch) external; + function lastProcessedBlock() external view returns (uint64); + function blocksInEpoch(uint64 epoch) external view returns (uint32); } } @@ -127,8 +130,6 @@ fn initialize_calldata( stakingToken: staking_token, activeValidatorsLength: 21, epochBlockInterval: 200, - felonyThreshold: 150, - validatorJailEpochLength: 7, undelegatePeriod: 7, minValidatorStakeAmount: TOKEN, minStakingAmount: TOKEN, @@ -319,3 +320,63 @@ fn genesis_staking_custodies_and_returns_blend_through_real_rwasm_calls() { initial_supply - TOKEN * U256::from(3) ); } + +// The independent ABI oracle for the recorder: alloy builds the calldata from +// its own signatures, so a selector that drifted from the Solidity name would +// land on `UnknownMethod()` here rather than on a passing unit test. +#[test] +fn record_production_drives_the_epoch_close_through_real_rwasm() { + let mut context = EvmTestingContext::default().with_full_genesis(); + let verifier = deploy_mock_bls_verifier(&mut context); + context = context.with_block_number(999); + call( + &mut context, + GENESIS_GOVERNANCE, + GENESIS_STAKING, + initialize_calldata(Address::repeat_byte(0x44), verifier, Vec::new()), + ); + + let record = |block_number: u64| { + IStakingRwasm::recordProductionCall { + blockNumber: block_number, + leaderIndex: 0, + } + .abi_encode() + }; + assert_reverts(&mut context, OWNER, GENESIS_STAKING, record(1_000)); + assert_reverts( + &mut context, + SYSTEM_CALLER, + GENESIS_STAKING, + IStakingRwasm::settleEpochStipendFromCall { epoch: 0 }.abi_encode(), + ); + + context = context.with_block_number(1_000); + call(&mut context, SYSTEM_CALLER, GENESIS_STAKING, record(1_000)); + call(&mut context, SYSTEM_CALLER, GENESIS_STAKING, record(1_000)); + // Crossing into epoch 1 runs the close, which must not fail the block. + context = context.with_block_number(1_200); + call(&mut context, SYSTEM_CALLER, GENESIS_STAKING, record(1_200)); + + let output = call( + &mut context, + OWNER, + GENESIS_STAKING, + IStakingRwasm::lastProcessedBlockCall {}.abi_encode(), + ); + assert_eq!( + IStakingRwasm::lastProcessedBlockCall::abi_decode_returns(&output).unwrap(), + 1_200 + ); + let output = call( + &mut context, + OWNER, + GENESIS_STAKING, + IStakingRwasm::blocksInEpochCall { epoch: 0 }.abi_encode(), + ); + assert_eq!( + IStakingRwasm::blocksInEpochCall::abi_decode_returns(&output).unwrap(), + 0, + "no committee is committed, so every block parks" + ); +} From 33a9d997c155b2243186cbfe4f3dbf167b94368a Mon Sep 17 00:00:00 2001 From: Aliaksei Dziadziuk Date: Mon, 3 Aug 2026 13:59:59 +0200 Subject: [PATCH 2/6] fix(codec): use one head stride for solidity array encode and decode 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. --- crates/codec/src/vec.rs | 28 +++- crates/codec/tests/roundtrip/mod.rs | 1 + .../tests/roundtrip/solidity_array_head.rs | 155 ++++++++++++++++++ 3 files changed, 179 insertions(+), 5 deletions(-) create mode 100644 crates/codec/tests/roundtrip/solidity_array_head.rs diff --git a/crates/codec/src/vec.rs b/crates/codec/src/vec.rs index 3ef8d5279..141e80ce7 100644 --- a/crates/codec/src/vec.rs +++ b/crates/codec/src/vec.rs @@ -100,6 +100,24 @@ where } } +/// Stride between element slots in a Solidity array head. +/// +/// Solidity reserves one 32-byte offset word per element when the element type is dynamic, and +/// inlines the element itself when it is static. `T::HEADER_SIZE` is the sum of the element's +/// field header sizes, not the width of its slot in the head — using it for a dynamic element +/// overshoots and writes into the previous element's tail. +fn solidity_head_stride() -> usize +where + B: ByteOrder, + T: Encoder, +{ + if T::IS_DYNAMIC { + ALIGN + } else { + align_up::(T::HEADER_SIZE) + } +} + // Implementation for Solidity mode impl Encoder for Vec where @@ -124,10 +142,10 @@ where } // Encode values - let mut value_encoder = BytesMut::zeroed(32 * self.len()); + let head_stride = solidity_head_stride::(); + let mut value_encoder = BytesMut::zeroed(head_stride * self.len()); for (index, obj) in self.iter().enumerate() { - let elem_offset = ALIGN.max(T::HEADER_SIZE) * index; - obj.encode(&mut value_encoder, elem_offset)?; + obj.encode(&mut value_encoder, head_stride * index)?; } let data = value_encoder.freeze(); @@ -147,9 +165,9 @@ where let mut result = Vec::with_capacity(data_len); let chunk = &buf.chunk()[(data_offset + 32) as usize..]; + let head_stride = solidity_head_stride::(); for i in 0..data_len { - let elem_offset = i * align_up::(T::HEADER_SIZE); - let value = T::decode(&chunk, elem_offset)?; + let value = T::decode(&chunk, head_stride * i)?; result.push(value); } diff --git a/crates/codec/tests/roundtrip/mod.rs b/crates/codec/tests/roundtrip/mod.rs index 1394230c5..d67ee0264 100644 --- a/crates/codec/tests/roundtrip/mod.rs +++ b/crates/codec/tests/roundtrip/mod.rs @@ -9,5 +9,6 @@ use fluentbase_codec::{ }; mod func; +mod solidity_array_head; mod structs; mod tuples; diff --git a/crates/codec/tests/roundtrip/solidity_array_head.rs b/crates/codec/tests/roundtrip/solidity_array_head.rs new file mode 100644 index 000000000..636a766b5 --- /dev/null +++ b/crates/codec/tests/roundtrip/solidity_array_head.rs @@ -0,0 +1,155 @@ +use super::*; + +#[derive(Codec, Default, Debug, Clone, PartialEq)] +struct DynElem { + blob: Bytes, + tag: FixedBytes<32>, + epoch: u64, +} + +sol! { + struct DynElemSol { + bytes blob; + bytes32 tag; + uint64 epoch; + } +} + +#[derive(Codec, Default, Debug, Clone, PartialEq)] +struct StaticWide { + who: Address, + hash: FixedBytes<32>, + epoch: u64, +} + +sol! { + struct StaticWideSol { + address who; + bytes32 hash; + uint64 epoch; + } +} + +fn dyn_elem(i: u8) -> DynElem { + DynElem { + blob: Bytes::from(vec![0xA0 + i; 3]), + tag: FixedBytes::<32>::from([0xB0 + i; 32]), + epoch: 0x1100 + i as u64, + } +} + +fn dyn_elem_sol(i: u8) -> DynElemSol { + DynElemSol { + blob: alloy_primitives::Bytes::from(vec![0xA0 + i; 3]), + tag: FixedBytes::<32>::from([0xB0 + i; 32]), + epoch: 0x1100 + i as u64, + } +} + +fn static_wide(i: u8) -> StaticWide { + StaticWide { + who: Address::from([0x10 + i; 20]), + hash: FixedBytes::<32>::from([0xB0 + i; 32]), + epoch: 0x1100 + i as u64, + } +} + +fn static_wide_sol(i: u8) -> StaticWideSol { + StaticWideSol { + who: Address::from([0x10 + i; 20]), + hash: FixedBytes::<32>::from([0xB0 + i; 32]), + epoch: 0x1100 + i as u64, + } +} + +fn encode_codec(values: &Vec) -> Vec +where + Vec: Encoder, +{ + let mut buf = BytesMut::new(); + SolidityABI::encode(values, &mut buf, 0).unwrap(); + buf.freeze().to_vec() +} + +fn assert_matches_alloy(n: usize, codec: &[u8], alloy: &[u8]) { + assert_eq!( + codec.len(), + alloy.len(), + "n={n}: length differs from alloy\ncodec: {}\nalloy: {}", + hex::encode(codec), + hex::encode(alloy), + ); + for (word, (got, want)) in codec.chunks(32).zip(alloy.chunks(32)).enumerate() { + assert_eq!( + got, + want, + "n={n}: word {word} (@{}) differs\ncodec: {}\nalloy: {}", + word * 32, + hex::encode(got), + hex::encode(want), + ); + } +} + +/// A dynamic element gets one 32-byte offset word in the head; the previous encoder strode by +/// `HEADER_SIZE` (72 here) and wrote element 1's head into element 0's tail. +#[test] +fn dynamic_element_array_matches_alloy() { + for n in 1..=4usize { + let values: Vec = (0..n).map(|i| dyn_elem(i as u8)).collect(); + let alloy: Vec = (0..n).map(|i| dyn_elem_sol(i as u8)).collect(); + + let encoded = encode_codec(&values); + assert_matches_alloy(n, &encoded, &alloy.abi_encode()); + + let decoded: Vec = SolidityABI::decode(&encoded.as_slice(), 0).unwrap(); + assert_eq!(decoded, values, "n={n}: codec round-trip"); + } +} + +/// A static element wider than one word is inlined in the head, so its stride is the aligned +/// `HEADER_SIZE`. This only diverges from `ALIGN.max(HEADER_SIZE)` once three elements are +/// present — the derive's own `align_up` masks the gap at n=1 and n=2. +#[test] +fn static_wide_element_array_matches_alloy() { + for n in 1..=4usize { + let values: Vec = (0..n).map(|i| static_wide(i as u8)).collect(); + let alloy: Vec = (0..n).map(|i| static_wide_sol(i as u8)).collect(); + + let encoded = encode_codec(&values); + assert_matches_alloy(n, &encoded, &alloy.abi_encode()); + + let decoded: Vec = SolidityABI::decode(&encoded.as_slice(), 0).unwrap(); + assert_eq!(decoded, values, "n={n}: codec round-trip"); + } +} + +#[test] +fn static_single_word_element_array_matches_alloy() { + for n in 1..=4usize { + let values: Vec
= (0..n).map(|i| Address::from([0x20 + i as u8; 20])).collect(); + + let encoded = encode_codec(&values); + assert_matches_alloy(n, &encoded, &values.abi_encode()); + + let decoded: Vec
= SolidityABI::decode(&encoded.as_slice(), 0).unwrap(); + assert_eq!(decoded, values, "n={n}: codec round-trip"); + } +} + +#[test] +fn decodes_alloy_encoded_arrays() { + for n in 1..=4usize { + let alloy_dyn: Vec = (0..n).map(|i| dyn_elem_sol(i as u8)).collect(); + let decoded: Vec = + SolidityABI::decode(&alloy_dyn.abi_encode().as_slice(), 0).unwrap(); + let expected: Vec = (0..n).map(|i| dyn_elem(i as u8)).collect(); + assert_eq!(decoded, expected, "n={n}: alloy bytes -> codec (dynamic)"); + + let alloy_wide: Vec = (0..n).map(|i| static_wide_sol(i as u8)).collect(); + let decoded: Vec = + SolidityABI::decode(&alloy_wide.abi_encode().as_slice(), 0).unwrap(); + let expected: Vec = (0..n).map(|i| static_wide(i as u8)).collect(); + assert_eq!(decoded, expected, "n={n}: alloy bytes -> codec (static wide)"); + } +} From c57ba5780c2cf305d2211b2695404fc1a3c86ca4 Mon Sep 17 00:00:00 2001 From: Aliaksei Dziadziuk Date: Mon, 3 Aug 2026 14:00:11 +0200 Subject: [PATCH 3/6] fix(codec): pad zero integers with zero bytes, not sign fill 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. --- crates/codec/src/primitive.rs | 12 +-- .../codec/tests/roundtrip/integer_padding.rs | 90 +++++++++++++++++++ crates/codec/tests/roundtrip/mod.rs | 1 + 3 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 crates/codec/tests/roundtrip/integer_padding.rs diff --git a/crates/codec/src/primitive.rs b/crates/codec/src/primitive.rs index 7cbe6dde1..a50adbbfb 100644 --- a/crates/codec/src/primitive.rs +++ b/crates/codec/src/primitive.rs @@ -111,6 +111,9 @@ macro_rules! impl_int { const HEADER_SIZE: usize = core::mem::size_of::<$typ>(); const IS_DYNAMIC: bool = false; + // `unused_comparisons`: the `>= 0` sign test below is vacuously true for the + // unsigned instantiations of this macro. + #[allow(unused_comparisons)] fn encode(&self, buf: &mut BytesMut, offset: usize) -> Result<(), CodecError> { let word_size = align_up::( >::HEADER_SIZE, @@ -125,11 +128,10 @@ macro_rules! impl_int { >::HEADER_SIZE, ); - B::$write_method(&mut buf[start..end], *self); - - // Fill the rest of the buffer with 0x00 or 0xFF depending on the sign of the - // integer - let fill_val = if *self > 0 { 0x00 } else { 0xFF }; + // Pad with the sign extension: 0xFF only for negative values. Zero is + // non-negative, so `>` here would pad it with 0xFF and produce a word no + // Solidity decoder accepts. + let fill_val = if *self >= 0 { 0x00 } else { 0xFF }; for i in offset..start { buf[i] = fill_val; diff --git a/crates/codec/tests/roundtrip/integer_padding.rs b/crates/codec/tests/roundtrip/integer_padding.rs new file mode 100644 index 000000000..bfbb60cf4 --- /dev/null +++ b/crates/codec/tests/roundtrip/integer_padding.rs @@ -0,0 +1,90 @@ +use super::*; + +fn encode_codec(value: T) -> Vec +where + T: Encoder, +{ + let mut buf = BytesMut::new(); + SolidityABI::encode(&value, &mut buf, 0).unwrap(); + buf.freeze().to_vec() +} + +fn assert_word_matches_alloy(value: T, alloy: S) +where + T: Encoder + core::fmt::Debug + Copy, + S: SolValue, +{ + let encoded = encode_codec(value); + assert_eq!( + hex::encode(&encoded), + hex::encode(alloy.abi_encode()), + "{value:?} does not match alloy", + ); +} + +/// Zero is non-negative, so it pads with 0x00. A `> 0` sign test pads it with 0xFF instead and +/// yields a word that strict Solidity decoders and topic filters reject. +#[test] +fn unsigned_integers_match_alloy() { + for v in [0u16, 1, u16::MAX] { + assert_word_matches_alloy(v, v); + } + for v in [0u32, 1, u32::MAX] { + assert_word_matches_alloy(v, v); + } + for v in [0u64, 1, u64::MAX] { + assert_word_matches_alloy(v, v); + } +} + +#[test] +fn signed_integers_match_alloy() { + for v in [0i16, 1, -1, i16::MIN, i16::MAX] { + assert_word_matches_alloy(v, v); + } + for v in [0i32, 1, -1, i32::MIN, i32::MAX] { + assert_word_matches_alloy(v, v); + } + for v in [0i64, 1, -1, i64::MIN, i64::MAX] { + assert_word_matches_alloy(v, v); + } +} + +/// The padding runs inside structs and tuples too, which is where events and return values hit +/// it — a standalone-value test alone would not cover the path that ships. +#[test] +fn zero_integer_inside_a_struct_matches_alloy() { + #[derive(Codec, Default, Debug, PartialEq)] + struct Counters { + recorded: u32, + expected: u32, + epoch: u64, + } + + sol! { + struct CountersSol { + uint32 recorded; + uint32 expected; + uint64 epoch; + } + } + + let value = Counters { + recorded: 0, + expected: 5, + epoch: 0, + }; + let mut buf = BytesMut::new(); + SolidityABI::encode(&value, &mut buf, 0).unwrap(); + + let alloy = CountersSol { + recorded: 0, + expected: 5, + epoch: 0, + }; + assert_eq!( + hex::encode(buf.freeze()), + hex::encode(alloy.abi_encode()), + "zero fields inside a struct must pad with 0x00", + ); +} diff --git a/crates/codec/tests/roundtrip/mod.rs b/crates/codec/tests/roundtrip/mod.rs index d67ee0264..a87f308e4 100644 --- a/crates/codec/tests/roundtrip/mod.rs +++ b/crates/codec/tests/roundtrip/mod.rs @@ -9,6 +9,7 @@ use fluentbase_codec::{ }; mod func; +mod integer_padding; mod solidity_array_head; mod structs; mod tuples; From 427ce20ea2580f2ee3c8dd3739b428c6da7276fe Mon Sep 17 00:00:00 2001 From: Aliaksei Dziadziuk Date: Mon, 3 Aug 2026 14:00:27 +0200 Subject: [PATCH 4/6] fix(staking): separate the reward claim cursor from the delegation checkpoint 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. --- contracts/staking/src/consensus.rs | 4 +- contracts/staking/src/staking.rs | 191 +++++++++++++++-------------- contracts/staking/src/storage.rs | 8 +- contracts/staking/src/tests.rs | 138 +++++++++++++++++---- 4 files changed, 228 insertions(+), 113 deletions(-) diff --git a/contracts/staking/src/consensus.rs b/contracts/staking/src/consensus.rs index e0c58006d..eac169e72 100644 --- a/contracts/staking/src/consensus.rs +++ b/contracts/staking/src/consensus.rs @@ -904,7 +904,9 @@ pub(crate) fn seize_self_stake( } queue.clear_checked(sdk)?; - delegation.delegate_gap_accessor().set_checked(sdk, 0)?; + delegation + .claimed_through_epoch_accessor() + .set_checked(sdk, 0)?; undelegates.clear_checked(sdk)?; delegation.undelegate_gap_accessor().set_checked(sdk, 0)?; pending_undelegated.set_checked(sdk, U256::ZERO)?; diff --git a/contracts/staking/src/staking.rs b/contracts/staking/src/staking.rs index 1ba4da5ed..671b79840 100644 --- a/contracts/staking/src/staking.rs +++ b/contracts/staking/src/staking.rs @@ -1352,45 +1352,38 @@ fn delegator_claimable( .entry(delegator); let delegates = delegation.delegate_queue_accessor(); let delegate_len = delegates.len_checked(sdk)?; - let mut delegate_gap = delegation.delegate_gap_accessor().get_checked(sdk)?; let mut claimable = U256::ZERO; - while delegate_gap < delegate_len { - let operation = delegates.at(delegate_gap); - let mut epoch = operation.epoch_accessor().get_checked(sdk)?; - if epoch >= reward_before_epoch { - break; - } - let changed_at = if delegate_gap + 1 < delegate_len { - delegates - .at(delegate_gap + 1) - .epoch_accessor() - .get_checked(sdk)? - } else { - reward_before_epoch - }; - let end = core::cmp::min(reward_before_epoch, changed_at); - let delegated = operation.amount_accessor().get_checked(sdk)?; - while epoch < end { - let (delegator_pool, _) = snapshot_payout(sdk, validator, epoch)?; - let snapshot = staking_storage() - .validator_snapshots_accessor() - .entry(validator) - .entry(epoch); - let total = snapshot.total_delegated_accessor().get_checked(sdk)?; - if !total.is_zero() { - claimable = claimable - .checked_add( - delegator_pool - .checked_mul(U256::from(delegated)) - .ok_or(ExitCode::IntegerOverflow)? - / U256::from(total), - ) - .ok_or(ExitCode::IntegerOverflow)?; + if let Some((mut index, mut epoch)) = delegate_claim_start(sdk, validator, delegator)? { + while index < delegate_len && epoch < reward_before_epoch { + let changed_at = if index + 1 < delegate_len { + delegates.at(index + 1).epoch_accessor().get_checked(sdk)? + } else { + reward_before_epoch + }; + let end = core::cmp::min(reward_before_epoch, changed_at); + let delegated = delegates.at(index).amount_accessor().get_checked(sdk)?; + while epoch < end { + let (delegator_pool, _) = snapshot_payout(sdk, validator, epoch)?; + let snapshot = staking_storage() + .validator_snapshots_accessor() + .entry(validator) + .entry(epoch); + let total = snapshot.total_delegated_accessor().get_checked(sdk)?; + if !total.is_zero() { + claimable = claimable + .checked_add( + delegator_pool + .checked_mul(U256::from(delegated)) + .ok_or(ExitCode::IntegerOverflow)? + / U256::from(total), + ) + .ok_or(ExitCode::IntegerOverflow)?; + } + epoch = epoch.checked_add(1).ok_or(ExitCode::IntegerOverflow)?; } - epoch = epoch.checked_add(1).ok_or(ExitCode::IntegerOverflow)?; + index += 1; } - delegate_gap += 1; } let undelegates = delegation.undelegate_queue_accessor(); @@ -1443,32 +1436,62 @@ fn validator_self_stake_lock( )) } -fn capped_delegator_reward_epoch( +/// Position to resume a reward claim from: the delegate-queue entry in force at the reward +/// cursor, and the epoch to start accruing at. `None` when the delegator has no delegations. +/// +/// The resume epoch is `max(cursor, first entry)` rather than the bare cursor: a delegator who +/// has never claimed carries a zero cursor, and windowing `MAX_EPOCHS_PER_CLAIM` from zero would +/// place the whole window before the first delegation and strand the funds permanently. +fn delegate_claim_start( sdk: &SDK, validator: Address, delegator: Address, - before_epoch: u64, -) -> Result { +) -> Result, ExitCode> { let delegation = staking_storage() .validator_delegations_accessor() .entry(validator) .entry(delegator); let delegates = delegation.delegate_queue_accessor(); - let delegate_gap = delegation.delegate_gap_accessor().get_checked(sdk)?; + let len = delegates.len_checked(sdk)?; + if len == 0 { + return Ok(None); + } + let cursor = delegation + .claimed_through_epoch_accessor() + .get_checked(sdk)?; + let first = delegates.at(0).epoch_accessor().get_checked(sdk)?; + let start = core::cmp::max(cursor, first); + + let mut low = 0; + let mut high = len; + while low < high { + let middle = low + (high - low) / 2; + if delegates.at(middle).epoch_accessor().get_checked(sdk)? <= start { + low = middle + 1; + } else { + high = middle; + } + } + Ok(Some((low - 1, start))) +} + +fn capped_delegator_reward_epoch( + sdk: &SDK, + validator: Address, + delegator: Address, + before_epoch: u64, +) -> Result { let settled_epoch_p1 = staking_storage() .last_rewarded_epoch_p1_accessor() .get_checked(sdk)?; let before_epoch = core::cmp::min(before_epoch, settled_epoch_p1); - if delegate_gap >= delegates.len_checked(sdk)? { - return Ok(before_epoch); - } - let first = delegates - .at(delegate_gap) - .epoch_accessor() - .get_checked(sdk)?; + let start = match delegate_claim_start(sdk, validator, delegator)? { + Some((_, start)) => start, + None => return Ok(before_epoch), + }; Ok(core::cmp::min( before_epoch, - first + start .checked_add(MAX_EPOCHS_PER_CLAIM) .ok_or(ExitCode::IntegerOverflow)?, )) @@ -1515,54 +1538,42 @@ fn consume_delegator_claim( .entry(delegator); let delegates = delegation.delegate_queue_accessor(); let delegate_len = delegates.len_checked(sdk)?; - let mut delegate_gap = delegation.delegate_gap_accessor().get_checked(sdk)?; let mut claimable = U256::ZERO; - while delegate_gap < delegate_len { - let operation = delegates.at(delegate_gap); - let mut epoch = operation.epoch_accessor().get_checked(sdk)?; - if epoch >= reward_before_epoch { - break; - } - let has_next = delegate_gap + 1 < delegate_len; - let changed_at = if has_next { - delegates - .at(delegate_gap + 1) - .epoch_accessor() - .get_checked(sdk)? - } else { - reward_before_epoch - }; - let end = core::cmp::min(reward_before_epoch, changed_at); - let delegated = operation.amount_accessor().get_checked(sdk)?; - while epoch < end { - let (delegator_pool, _) = snapshot_payout(sdk, validator, epoch)?; - let snapshot = storage - .validator_snapshots_accessor() - .entry(validator) - .entry(epoch); - let total = snapshot.total_delegated_accessor().get_checked(sdk)?; - if !total.is_zero() { - claimable = claimable - .checked_add( - delegator_pool - .checked_mul(U256::from(delegated)) - .ok_or(ExitCode::IntegerOverflow)? - / U256::from(total), - ) - .ok_or(ExitCode::IntegerOverflow)?; + if let Some((mut index, mut epoch)) = delegate_claim_start(sdk, validator, delegator)? { + while index < delegate_len && epoch < reward_before_epoch { + let changed_at = if index + 1 < delegate_len { + delegates.at(index + 1).epoch_accessor().get_checked(sdk)? + } else { + reward_before_epoch + }; + let end = core::cmp::min(reward_before_epoch, changed_at); + let delegated = delegates.at(index).amount_accessor().get_checked(sdk)?; + while epoch < end { + let (delegator_pool, _) = snapshot_payout(sdk, validator, epoch)?; + let snapshot = storage + .validator_snapshots_accessor() + .entry(validator) + .entry(epoch); + let total = snapshot.total_delegated_accessor().get_checked(sdk)?; + if !total.is_zero() { + claimable = claimable + .checked_add( + delegator_pool + .checked_mul(U256::from(delegated)) + .ok_or(ExitCode::IntegerOverflow)? + / U256::from(total), + ) + .ok_or(ExitCode::IntegerOverflow)?; + } + epoch = epoch.checked_add(1).ok_or(ExitCode::IntegerOverflow)?; } - epoch = epoch.checked_add(1).ok_or(ExitCode::IntegerOverflow)?; + index += 1; } - if !has_next || epoch < changed_at { - operation.epoch_accessor().set_checked(sdk, epoch)?; - break; - } - delegate_gap += 1; + delegation + .claimed_through_epoch_accessor() + .set_checked(sdk, epoch)?; } - delegation - .delegate_gap_accessor() - .set_checked(sdk, delegate_gap)?; let undelegates = delegation.undelegate_queue_accessor(); let undelegate_len = undelegates.len_checked(sdk)?; diff --git a/contracts/staking/src/storage.rs b/contracts/staking/src/storage.rs index 912bec2fa..af7bda48e 100644 --- a/contracts/staking/src/storage.rs +++ b/contracts/staking/src/storage.rs @@ -125,7 +125,6 @@ pub struct UndelegationOpStorage { #[allow(dead_code)] pub struct ValidatorDelegationStorage { delegate_queue: StorageVec, - delegate_gap: StorageU64, undelegate_queue: StorageVec, undelegate_gap: StorageU64, /// Unclaimed queued principal in full-precision token units. @@ -133,6 +132,13 @@ pub struct ValidatorDelegationStorage { /// Keeping the aggregate beside the operation history lets equivocation /// seizure remain constant-time even when the queue is fragmented. pending_undelegated: StorageU256, + /// Exclusive epoch through which rewards have been paid. + /// + /// Separate from `DelegationOpStorage::epoch`, which is the epoch a balance + /// takes effect from and must stay immutable: historical stake lookups + /// binary-search that field, so advancing it as a payment cursor rewrites + /// past-epoch committee views. + claimed_through_epoch: StorageU64, } /// Epoch-stamped selection visibility. Status changes become visible from the diff --git a/contracts/staking/src/tests.rs b/contracts/staking/src/tests.rs index fd72864c0..8f631f041 100644 --- a/contracts/staking/src/tests.rs +++ b/contracts/staking/src/tests.rs @@ -729,26 +729,52 @@ fn solidity_bytes_outputs_and_event_match_cast_vectors() { ) ); - let keys = vec![ConsensusKeys { - bls_pubkey: Bytes::from_static(&[0xaa, 0xbb, 0xcc]), - peer_pubkey: B256::with_last_byte(0x01), - activation_epoch: 7, - }]; + // Three elements, not one: a single-element array puts every head slot at offset 0, so it + // cannot catch a wrong stride between slots. + let keys = vec![ + ConsensusKeys { + bls_pubkey: Bytes::from_static(&[0xaa, 0xbb, 0xcc]), + peer_pubkey: B256::with_last_byte(0x01), + activation_epoch: 7, + }, + ConsensusKeys { + bls_pubkey: Bytes::from_static(&[0xdd, 0xee]), + peer_pubkey: B256::with_last_byte(0x02), + activation_epoch: 8, + }, + ConsensusKeys { + bls_pubkey: Bytes::from_static(&[0xff]), + peer_pubkey: B256::with_last_byte(0x03), + activation_epoch: 9, + }, + ]; let mut encoded_keys = BytesMut::new(); SolidityABI::>::encode(&keys, &mut encoded_keys, 0).unwrap(); // cast abi-encode "f((bytes,bytes32,uint64)[])" - // "[(0xaabbcc,0x...01,7)]" + // "[(0xaabbcc,0x...01,7),(0xddee,0x...02,8),(0xff,0x...03,9)]" assert_eq!( encoded_keys.as_ref(), &hex!( "0000000000000000000000000000000000000000000000000000000000000020 - 0000000000000000000000000000000000000000000000000000000000000001 - 0000000000000000000000000000000000000000000000000000000000000020 + 0000000000000000000000000000000000000000000000000000000000000003 + 0000000000000000000000000000000000000000000000000000000000000060 + 0000000000000000000000000000000000000000000000000000000000000100 + 00000000000000000000000000000000000000000000000000000000000001a0 0000000000000000000000000000000000000000000000000000000000000060 0000000000000000000000000000000000000000000000000000000000000001 0000000000000000000000000000000000000000000000000000000000000007 0000000000000000000000000000000000000000000000000000000000000003 - aabbcc0000000000000000000000000000000000000000000000000000000000" + aabbcc0000000000000000000000000000000000000000000000000000000000 + 0000000000000000000000000000000000000000000000000000000000000060 + 0000000000000000000000000000000000000000000000000000000000000002 + 0000000000000000000000000000000000000000000000000000000000000008 + 0000000000000000000000000000000000000000000000000000000000000002 + ddee000000000000000000000000000000000000000000000000000000000000 + 0000000000000000000000000000000000000000000000000000000000000060 + 0000000000000000000000000000000000000000000000000000000000000003 + 0000000000000000000000000000000000000000000000000000000000000009 + 0000000000000000000000000000000000000000000000000000000000000001 + ff00000000000000000000000000000000000000000000000000000000000000" ) ); @@ -4007,11 +4033,11 @@ fn delayed_reward_settlement_does_not_block_matured_principal() { assert_eq!(transfers.borrow().as_slice(), &[(delegator, stake)]); assert_eq!( delegation - .delegate_gap_accessor() + .claimed_through_epoch_accessor() .get_checked(&harness.sdk) .unwrap(), - 0, - "principal maturity must not consume the unsettled reward cursor" + u64::from(WARMUP_DELAY), + "principal maturity must not advance the reward cursor past the settled frontier" ); assert_eq!( delegation @@ -4050,10 +4076,68 @@ fn delayed_reward_settlement_does_not_block_matured_principal() { ); assert_eq!( delegation - .delegate_gap_accessor() + .claimed_through_epoch_accessor() .get_checked(&harness.sdk) .unwrap(), - 1 + u64::from(WARMUP_DELAY) + 1, + "the settled epoch is now paid, so the cursor sits one past it" + ); +} + +#[test] +fn claiming_rewards_does_not_rewrite_historical_self_stake() { + let owner = Address::with_last_byte(0xa0); + let validator = Address::with_last_byte(0x01); + let mut harness = Harness::new(0); + harness.set_caller(owner); + assert_eq!( + harness.initialize( + owner, + vec![validator], + vec![DEFAULT_MIN_VALIDATOR_STAKE], + 0, + ), + ExitCode::Ok + ); + + // A genesis validator is its own delegator, so its self-stake is the single delegate-queue + // entry the claim walks. Settling a frontier is what makes the claim advance at all. + let past_epoch = 20; + harness.set_block_number(DEFAULT_EPOCH_BLOCK_INTERVAL * 40); + staking_storage() + .last_rewarded_epoch_p1_accessor() + .set_checked(&mut harness.sdk, 40) + .unwrap(); + + let stake_before = + staking::validator_self_stake_at(&harness.sdk, validator, past_epoch).unwrap(); + assert!(!stake_before.is_zero()); + assert!(staking::selected_validators_at(&harness.sdk, past_epoch) + .unwrap() + .contains(&validator)); + + harness.set_caller(validator); + assert_eq!( + harness + .call(encode_call( + SIG_CLAIM_DELEGATOR_FEE, + &AddressCommand { value: validator }, + )) + .0, + ExitCode::Ok + ); + + assert_eq!( + staking::validator_self_stake_at(&harness.sdk, validator, past_epoch).unwrap(), + stake_before, + "a claim must not change what the self-stake was at an already-committed epoch" + ); + assert!( + staking::selected_validators_at(&harness.sdk, past_epoch) + .unwrap() + .contains(&validator), + "the off-chain deriver re-reads past-epoch selection to rebuild committees; a claim must \ + not drop the validator out of it" ); } @@ -4087,17 +4171,28 @@ fn reward_claims_are_bounded_to_one_thousand_epochs() { .0, ExitCode::Ok ); + let delegation = staking_storage() + .validator_delegations_accessor() + .entry(validator) + .entry(validator); assert_eq!( - staking_storage() - .validator_delegations_accessor() - .entry(validator) - .entry(validator) + delegation + .claimed_through_epoch_accessor() + .get_checked(&harness.sdk) + .unwrap(), + MAX_EPOCHS_PER_CLAIM, + "the claim advances its own cursor by at most the per-claim bound" + ); + assert_eq!( + delegation .delegate_queue_accessor() .at(0) .epoch_accessor() .get_checked(&harness.sdk) .unwrap(), - MAX_EPOCHS_PER_CLAIM + 0, + "the effective-from epoch is immutable: historical self-stake lookups binary-search it, \ + so a claim that moved it would rewrite past-epoch committee views" ); assert_eq!( harness @@ -4651,10 +4746,11 @@ fn equivocation_seizes_active_and_pending_self_delegation() { ); assert_eq!( delegation - .delegate_gap_accessor() + .claimed_through_epoch_accessor() .get_checked(&harness.sdk) .unwrap(), - 0 + 0, + "seizure resets the reward cursor with the queues" ); assert_eq!( delegation From 68c8ebb2bef950720f74716bfeb3195accea83a2 Mon Sep 17 00:00:00 2001 From: Aliaksei Dziadziuk Date: Mon, 3 Aug 2026 16:32:36 +0200 Subject: [PATCH 5/6] fix(staking): treat a zero activation block as unarmed when deriving 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. --- contracts/staking/src/math.rs | 17 +++++++- contracts/staking/src/tests.rs | 71 +++++++++++++++++++++++++++++++++- 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/contracts/staking/src/math.rs b/contracts/staking/src/math.rs index ca4935bc7..1bac36a26 100644 --- a/contracts/staking/src/math.rs +++ b/contracts/staking/src/math.rs @@ -38,11 +38,19 @@ pub fn fault_tolerance(n: usize) -> usize { } /// Map a block to its activation-relative epoch, clamping pre-activation blocks. +/// +/// A zero activation block is the unarmed sentinel, not "armed at genesis". +/// `ensure_dpos_not_active` keeps the governance setters open on it and the node +/// reads it the same way, so this must not disagree with them. Without the first +/// half of the clamp an unsigned `block_number < 0` is never true, so an unarmed +/// chain counts epochs from genesis and then drops them back to zero the moment +/// a real activation block is scheduled — a backwards jump that rewrites which +/// delegation checkpoint a stake belongs to. pub fn epoch_at_block(block_number: u64, activation_block: u64, interval: u64) -> Option { if interval == 0 { return None; } - if block_number < activation_block { + if activation_block == 0 || block_number < activation_block { return Some(0); } Some((block_number - activation_block) / interval) @@ -81,4 +89,11 @@ mod tests { assert_eq!(epoch_at_block(140, 100, 20), Some(2)); assert_eq!(epoch_at_block(140, 100, 0), None); } + + #[test] + fn unarmed_activation_pins_the_epoch_regardless_of_height() { + assert_eq!(epoch_at_block(0, 0, 200), Some(0)); + assert_eq!(epoch_at_block(4_000, 0, 200), Some(0)); + assert_eq!(epoch_at_block(u64::MAX, 0, 200), Some(0)); + } } diff --git a/contracts/staking/src/tests.rs b/contracts/staking/src/tests.rs index 8f631f041..385e7446e 100644 --- a/contracts/staking/src/tests.rs +++ b/contracts/staking/src/tests.rs @@ -3165,6 +3165,15 @@ fn dpos_activation_at_block_zero_remains_configurable() { .0, ExitCode::Ok ); + + // Still unarmed here: the height crosses several interval boundaries and the + // epoch must not follow it. Configurable and running are two different + // states, and the zero activation block means the first one. + harness.set_block_number(4_000); + let (_, output) = harness.call(encode_empty_call(SIG_CURRENT_EPOCH)); + assert_eq!(decode_output::(&output), 0); + harness.set_block_number(0); + assert_eq!( harness .call(encode_call( @@ -3176,6 +3185,59 @@ fn dpos_activation_at_block_zero_remains_configurable() { ); } +#[test] +fn scheduling_activation_never_moves_the_epoch_backwards() { + let owner = Address::with_last_byte(0xa0); + let mut harness = Harness::new(0); + assert_eq!( + harness.initialize(owner, Vec::new(), Vec::new(), 0), + ExitCode::Ok + ); + + fn epoch(harness: &mut Harness) -> u64 { + let (_, output) = harness.call(encode_empty_call(SIG_CURRENT_EPOCH)); + decode_output::(&output) + } + + harness.set_block_number(4_000); + assert_eq!( + epoch(&mut harness), + 0, + "an unarmed chain must not accrue epochs from genesis" + ); + + harness.set_caller(GENESIS_GOVERNANCE); + assert_eq!( + harness + .call(encode_call( + SIG_SET_DPOS_ACTIVATION_BLOCK, + &U64Command { value: 6_000 }, + )) + .0, + ExitCode::Ok + ); + + // Arming is what used to drop a running counter back to zero: the epoch was + // derived from the height alone while unarmed, so scheduling a real + // activation rebased it downward and rewrote which checkpoint a stake fell + // under. Every step from here must be non-decreasing. + let mut previous = epoch(&mut harness); + assert_eq!(previous, 0); + for height in [4_001u64, 5_999, 6_000, 6_199, 6_200, 6_400] { + harness.set_block_number(height); + let current = epoch(&mut harness); + assert!( + current >= previous, + "epoch moved backwards at block {height}: {previous} -> {current}" + ); + previous = current; + } + assert_eq!( + previous, 2, + "epochs count from the activation block once armed" + ); +} + #[test] fn undelegate_period_change_does_not_shorten_queued_self_stake_lock() { let sponsor = Address::with_last_byte(0xa0); @@ -4145,7 +4207,11 @@ fn claiming_rewards_does_not_rewrite_historical_self_stake() { fn reward_claims_are_bounded_to_one_thousand_epochs() { let owner = Address::with_last_byte(0xa0); let validator = Address::with_last_byte(0x01); - let mut harness = Harness::new(0); + // Not block 0: the harness seeds the activation block from the current + // height, and a zero activation is the unarmed sentinel, which pins every + // epoch at 0 and leaves this claim window empty. + let activation = DEFAULT_EPOCH_BLOCK_INTERVAL; + let mut harness = Harness::new(activation); harness.set_caller(owner); assert_eq!( harness.initialize( @@ -4156,7 +4222,8 @@ fn reward_claims_are_bounded_to_one_thousand_epochs() { ), ExitCode::Ok ); - harness.set_block_number(DEFAULT_EPOCH_BLOCK_INTERVAL * (MAX_EPOCHS_PER_CLAIM + 1)); + harness + .set_block_number(activation + DEFAULT_EPOCH_BLOCK_INTERVAL * (MAX_EPOCHS_PER_CLAIM + 1)); staking_storage() .last_rewarded_epoch_p1_accessor() .set_checked(&mut harness.sdk, MAX_EPOCHS_PER_CLAIM + 1) From 61cbe4c8bb3c6e4feaab31fa04c7494b35ef4919 Mon Sep 17 00:00:00 2001 From: Aliaksei Dziadziuk Date: Mon, 3 Aug 2026 16:33:08 +0200 Subject: [PATCH 6/6] fix(staking): pay an epoch the stipend rate it closed at, not the live 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. --- contracts/staking/src/consts.rs | 2 + contracts/staking/src/liveness.rs | 13 +++ contracts/staking/src/staking.rs | 14 ++- contracts/staking/src/storage.rs | 8 ++ contracts/staking/src/tests.rs | 161 ++++++++++++++++++++++++++++++ 5 files changed, 196 insertions(+), 2 deletions(-) diff --git a/contracts/staking/src/consts.rs b/contracts/staking/src/consts.rs index 8066381a5..a22c50101 100644 --- a/contracts/staking/src/consts.rs +++ b/contracts/staking/src/consts.rs @@ -345,6 +345,8 @@ pub const ERR_COMMITTEE_LENGTH_MISMATCH: u32 = pub const ERR_COMMITTEE_TOO_SMALL: u32 = derive_keccak256_id!("CommitteeTooSmall(uint256,uint256)"); pub const ERR_LEADER_STAKES_LENGTH_MISMATCH: u32 = derive_keccak256_id!("LeaderStakesLengthMismatch(uint64,uint256,uint256)"); +pub const ERR_STIPEND_RATE_NOT_SNAPSHOTTED: u32 = + derive_keccak256_id!("StipendRateNotSnapshotted(uint64)"); pub const ERR_EPOCH_NOT_YET_COMMITTABLE: u32 = derive_keccak256_id!("EpochNotYetCommittable(uint64,uint64)"); pub const ERR_COMMITTEE_MEMBER_KEYLESS: u32 = diff --git a/contracts/staking/src/liveness.rs b/contracts/staking/src/liveness.rs index f9128bef7..f9982be14 100644 --- a/contracts/staking/src/liveness.rs +++ b/contracts/staking/src/liveness.rs @@ -185,6 +185,19 @@ fn close_epoch(sdk: &mut SDK, epoch: u64) -> Result<(), ExitCode // An epoch that was never recorded at all is reachable and must not draw a // full pot for no work. if recorded > 0 { + // Pin the rate before settling. `settle_up_to` walks the cursor across a + // range, so a rate read at payment time prices every caught-up epoch at + // today's value. Stored `+1` so "never closed" stays distinguishable + // from "closed at a rate of zero". + let rate = config.blend_stipend_per_epoch_accessor().get_checked(sdk)?; + production_liveness_storage() + .stipend_rate_at_close_p1_accessor() + .entry(epoch) + .set_checked( + sdk, + rate.checked_add(U256::ONE) + .ok_or(ExitCode::IntegerOverflow)?, + )?; settle_stipend_leg(sdk, epoch)?; } Ok(()) diff --git a/contracts/staking/src/staking.rs b/contracts/staking/src/staking.rs index 671b79840..7015aa9fd 100644 --- a/contracts/staking/src/staking.rs +++ b/contracts/staking/src/staking.rs @@ -1975,9 +1975,19 @@ fn settle_one(sdk: &mut SDK, epoch: u64, reserve: Address) -> Re } let committee = consensus.epoch_committees_accessor().entry(epoch); let len = committee.len_checked(sdk)?; - let desired = chain_config_storage() - .blend_stipend_per_epoch_accessor() + // The rate this epoch worked under, pinned by its own close. Reading the + // live config here would let a rate change between the epoch ending and the + // epoch being paid rewrite what it earned, and the cursor never comes back. + let pinned = production_liveness_storage() + .stipend_rate_at_close_p1_accessor() + .entry(epoch) .get_checked(sdk)?; + if pinned.is_zero() { + // A revert defers, a guard return forfeits — see this function's caller. + // An epoch whose price is unknown belongs on the deferred side. + return revert_with(sdk, ERR_STIPEND_RATE_NOT_SNAPSHOTTED, &epoch); + } + let desired = pinned - U256::ONE; if desired.is_zero() { events::EpochBlendRewardsCommitted { epoch, diff --git a/contracts/staking/src/storage.rs b/contracts/staking/src/storage.rs index af7bda48e..73e275c81 100644 --- a/contracts/staking/src/storage.rs +++ b/contracts/staking/src/storage.rs @@ -250,6 +250,14 @@ pub struct ProductionLivenessStorage { /// Live exclusions; the length is the concurrent count. pending_exclusions: StorageVec, validators: StorageMap, + /// Stipend rate in force when the epoch closed, stored as `rate + 1`. + /// + /// Zero means "this epoch never closed", which is not the same as a rate of + /// zero. Settlement must not price an epoch from the live config: the rate + /// can change between the epoch being worked and the epoch being paid, the + /// cursor never returns to a settled epoch, and the committee weights three + /// lines below it are already frozen for exactly this reason. + stipend_rate_at_close_p1: StorageMap, } pub fn initializer_storage() -> InitializerStorage { diff --git a/contracts/staking/src/tests.rs b/contracts/staking/src/tests.rs index 385e7446e..99abc6582 100644 --- a/contracts/staking/src/tests.rs +++ b/contracts/staking/src/tests.rs @@ -317,6 +317,26 @@ fn record_test_production(sdk: &mut TestingContextImpl, epoch: u64, blocks: u32) .entry(epoch) .set_checked(sdk, blocks) .unwrap(); + pin_test_stipend_rate(sdk, epoch); +} + +/// Pin the stipend rate the way `close_epoch` does. +/// +/// Seeding the counters without the rate leaves settlement unable to price the +/// epoch, which the contract now rejects. Pinning inside the seeding helpers +/// rather than at their call sites is what keeps the stand-in from drifting away +/// from the production path again — the callers must only make sure the rate is +/// configured *before* they seed. +fn pin_test_stipend_rate(sdk: &mut TestingContextImpl, epoch: u64) { + let rate = chain_config_storage() + .blend_stipend_per_epoch_accessor() + .get_checked(sdk) + .unwrap(); + production_liveness_storage() + .stipend_rate_at_close_p1_accessor() + .entry(epoch) + .set_checked(sdk, rate + U256::ONE) + .unwrap(); } enum MockDisbursement { @@ -3794,6 +3814,146 @@ fn an_epoch_that_recorded_no_blocks_is_skipped_when_a_later_one_settles() { // Truncating to the shorter of the two arrays would hand the whole pot to a // committee prefix and then advance the cursor past the epoch for good, so the // settle path must refuse a mismatch exactly as the reader does. +#[test] +fn stipend_pays_the_rate_pinned_at_close_not_the_live_one() { + let pot = U256::from(100); + let (mut harness, calls, validator) = + stipend_test_sdk(vec![pot], vec![MockDisbursement::Amount(pot)]); + + // The rate the epoch worked under is already pinned. Dropping the live one to + // zero afterwards is the governance action that used to erase the epoch's pay + // outright: settlement returns Ok, the cursor moves past it, and no later + // call can revisit it. + chain_config_storage() + .blend_stipend_per_epoch_accessor() + .set_checked(&mut harness.sdk, U256::ZERO) + .unwrap(); + harness.set_caller(SYSTEM_CALLER); + assert_eq!( + harness + .call(encode_call( + SIG_SETTLE_EPOCH_STIPEND, + &U64Command { value: 0 }, + )) + .0, + ExitCode::Ok + ); + assert_eq!(stipend_accounting(&harness.sdk, validator), (pot, pot, 1)); + assert_eq!(calls.borrow().disburse_calls, vec![(GENESIS_STAKING, pot)]); + assert_stipend_events(&harness.sdk, 0, pot, false); + + // The mirror image: a raised live rate must not enrich an epoch that closed + // under a lower one either. + let (mut harness, calls, validator) = + stipend_test_sdk(vec![pot], vec![MockDisbursement::Amount(pot)]); + production_liveness_storage() + .stipend_rate_at_close_p1_accessor() + .entry(0) + .set_checked(&mut harness.sdk, U256::ONE) + .unwrap(); + harness.set_caller(SYSTEM_CALLER); + assert_eq!( + harness + .call(encode_call( + SIG_SETTLE_EPOCH_STIPEND, + &U64Command { value: 0 }, + )) + .0, + ExitCode::Ok + ); + assert_eq!( + stipend_accounting(&harness.sdk, validator), + (U256::ZERO, U256::ZERO, 1) + ); + assert!(calls.borrow().disburse_calls.is_empty()); + assert_stipend_events(&harness.sdk, 0, U256::ZERO, false); +} + +#[test] +fn closing_an_epoch_pins_the_stipend_rate() { + let owner = Address::with_last_byte(0xa0); + let validator = Address::with_last_byte(0x01); + let mut harness = Harness::new(1_000); + assert_eq!( + harness.initialize(owner, vec![validator], vec![DEFAULT_MIN_VALIDATOR_STAKE], 0), + ExitCode::Ok + ); + chain_config_storage() + .blend_stipend_per_epoch_accessor() + .set_checked(&mut harness.sdk, U256::from(250)) + .unwrap(); + commit_test_committee( + &mut harness.sdk, + 0, + &[(validator, DEFAULT_MIN_VALIDATOR_STAKE)], + ); + // Written raw rather than through `record_test_production`, which pins the + // rate itself. The whole point of this test is that the contract pins it, so + // seeding through the stand-in would prove nothing. + production_liveness_storage() + .blocks_in_epoch_accessor() + .entry(0) + .set_checked(&mut harness.sdk, DEFAULT_EPOCH_BLOCK_INTERVAL as u32) + .unwrap(); + let pinned = production_liveness_storage() + .stipend_rate_at_close_p1_accessor() + .entry(0); + assert_eq!(pinned.get_checked(&harness.sdk).unwrap(), U256::ZERO); + + assert_eq!(close_epoch_via_record(&mut harness, 0), ExitCode::Ok); + + assert_eq!( + pinned.get_checked(&harness.sdk).unwrap(), + U256::from(251), + "the close pins rate + 1 for the epoch it closes" + ); +} + +#[test] +fn settling_an_unclosed_epoch_reverts_instead_of_forfeiting_it() { + let owner = Address::with_last_byte(0xa0); + let validator = Address::with_last_byte(0x01); + let mut harness = Harness::new(1_000); + assert_eq!( + harness.initialize(owner, vec![validator], vec![DEFAULT_MIN_VALIDATOR_STAKE], 0), + ExitCode::Ok + ); + chain_config_storage() + .blend_stipend_per_epoch_accessor() + .set_checked(&mut harness.sdk, U256::from(100)) + .unwrap(); + commit_test_committee( + &mut harness.sdk, + 0, + &[(validator, DEFAULT_MIN_VALIDATOR_STAKE)], + ); + // An epoch that recorded blocks but whose close never ran: seeded raw, so no + // rate was pinned for it. + production_liveness_storage() + .blocks_in_epoch_accessor() + .entry(0) + .set_checked(&mut harness.sdk, DEFAULT_EPOCH_BLOCK_INTERVAL as u32) + .unwrap(); + harness.set_block_number(1_000 + DEFAULT_EPOCH_BLOCK_INTERVAL); + harness.set_caller(SYSTEM_CALLER); + + assert_revert_selector( + harness.call(encode_call( + SIG_SETTLE_EPOCH_STIPEND, + &U64Command { value: 0 }, + )), + ERR_STIPEND_RATE_NOT_SNAPSHOTTED, + ); + assert_eq!( + staking_storage() + .last_rewarded_epoch_p1_accessor() + .get_checked(&harness.sdk) + .unwrap(), + 0, + "a revert leaves the epoch for a retry; a guard return would forfeit it" + ); +} + #[test] fn settlement_rejects_a_committee_without_matching_frozen_weights() { let owner = Address::with_last_byte(0xa0); @@ -5665,6 +5825,7 @@ fn seed_epoch_production( .entry(epoch) .set_checked(sdk, recorded) .unwrap(); + pin_test_stipend_rate(sdk, epoch); } /// Drives the close of `epoch` by recording the first block of `epoch + 1`.