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..eac169e72 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_"; @@ -1142,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)?; @@ -1290,8 +1054,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..a22c50101 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,10 @@ 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_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 = @@ -369,8 +381,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 +391,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 +420,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 +440,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..f9982be14 --- /dev/null +++ b/contracts/staking/src/liveness.rs @@ -0,0 +1,605 @@ +//! 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 { + // 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(()) +} + +/// 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..1bac36a26 100644 --- a/contracts/staking/src/math.rs +++ b/contracts/staking/src/math.rs @@ -25,12 +25,32 @@ 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. +/// +/// 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) @@ -69,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/staking.rs b/contracts/staking/src/staking.rs index 0452cf0f6..7015aa9fd 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) } @@ -1253,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(); @@ -1344,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)?, )) @@ -1416,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)?; - } - if !has_next || epoch < changed_at { - operation.epoch_accessor().set_checked(sdk, epoch)?; - break; + index += 1; } - 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)?; @@ -1842,27 +1952,42 @@ 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() - .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, @@ -1877,75 +2002,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 +2099,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 +2127,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 +2136,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..73e275c81 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 { @@ -103,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. @@ -111,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 @@ -139,7 +167,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 +176,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 +184,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 +215,51 @@ 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, + /// 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 { InitializerStorage::new(INITIALIZER_STORAGE_SLOT, 0) } @@ -197,3 +275,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..99abc6582 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,48 @@ 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(); + 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 { Amount(U256), EmptyReturn, @@ -265,9 +346,7 @@ enum MockDisbursement { } struct StipendCallState { - liveness: Address, reserve: Address, - finalized_epoch_p1: u64, reserve_balances: VecDeque, disbursements: VecDeque, reserve_balance_reads: usize, @@ -317,7 +396,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 +408,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 +440,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) = @@ -685,26 +749,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" ) ); @@ -873,15 +963,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 +987,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 +1013,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 +1142,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 +1151,6 @@ fn initializes_registry_and_preserves_solidity_read_abi() { U256::from(10) * DEFAULT_MIN_VALIDATOR_STAKE, 0, 0, - 0, - 0, 500, ) ); @@ -1153,10 +1320,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 +1328,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 +1348,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 +1425,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 +1459,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 +1482,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 +1495,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 +1514,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 +1545,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 +1571,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 +2054,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,1140 +2118,1309 @@ 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(); + harness.set_caller(GENESIS_GOVERNANCE); + harness.sdk.take_logs(); + assert_eq!( + 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!( + decode_output::<(u32, u32, u64)>(data), + (1, 2, 1), + "the event must announce the epoch the new cap first governs, not the current one" + ); - 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::validator_total_at(&harness.sdk, validator, 999_999).unwrap(), - stake + 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_000_000).unwrap(), - stake * U256::from(2) + 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!( + decode_output::(&output), + 2, + "the scalar reports the latest scheduled value immediately" ); } +// 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 undelegation_rejects_a_later_pending_delegation_checkpoint() { +fn keys_activating_after_the_selection_epoch_are_filtered_after_the_cut() { 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 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); - harness.set_caller(owner); - assert_eq!( - harness.initialize( - owner, - vec![validator], - vec![DEFAULT_MIN_VALIDATOR_STAKE], - 500, - ), - ExitCode::Ok + 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); - 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!( - staking::validator_total_at(&harness.sdk, validator, 1).unwrap(), - DEFAULT_MIN_VALIDATOR_STAKE - ); + 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 + view, + vec![future, keyed], + "the not-yet-activated validator still occupies its top-k slot" ); + assert!(view_keys[0].bls_pubkey.is_empty()); - 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 + 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!( - staking::validator_total_at(&harness.sdk, validator, 2).unwrap(), - DEFAULT_MIN_VALIDATOR_STAKE + delegated - undelegated + harness + .call(encode_args_call(SIG_COMMIT_EPOCH_COMMITTEE, &(vec![keyed],))) + .0, + ExitCode::Ok ); - let latest = staking_storage() - .validator_delegations_accessor() - .entry(validator) - .entry(delegator) - .delegate_queue_accessor() - .at(0); +} + +#[test] +fn committee_stake_read_rejects_a_committee_without_matching_frozen_weights() { + let owner = Address::with_last_byte(0xa0); + let validator = Address::with_last_byte(0x01); + let mut harness = Harness::new(1_000); assert_eq!( - math::expand_balance(latest.amount_accessor().get_checked(&harness.sdk).unwrap()), - delegated - undelegated + harness.initialize(owner, vec![validator], vec![DEFAULT_MIN_VALIDATOR_STAKE], 0), + ExitCode::Ok ); - assert_eq!( - latest.epoch_accessor().get_checked(&harness.sdk).unwrap(), - 2 + + // 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(); + + assert_revert_selector( + harness.call(encode_call( + SIG_GET_EPOCH_COMMITTEE_WITH_STAKES, + &U64Command { value: 4 }, + )), + ERR_LEADER_STAKES_LENGTH_MISMATCH, ); } #[test] -fn reward_views_split_blend_between_owner_and_delegators() { +fn repeated_cap_changes_in_one_epoch_collapse_into_a_single_checkpoint() { 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) - .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); + 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 + ); + } - let (_, output) = harness.call(encode_call( - SIG_GET_DELEGATOR_FEE, - &ValidatorDelegatorCommand { - validator, - delegator, - }, - )); assert_eq!( - decode_output::(&output), - U256::from(9) * DEFAULT_MIN_STAKING_AMOUNT / U256::from(2) + 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_CALC_AVAILABLE_FOR_REDELEGATE_AMOUNT, - &ValidatorDelegatorCommand { - validator, - delegator, - }, + SIG_GET_ACTIVE_VALIDATORS_LENGTH_AT, + &U64Command { value: 1 }, )); - assert_eq!( - decode_output::<(U256, U256)>(&output), - ( - U256::from(9) * DEFAULT_MIN_STAKING_AMOUNT / U256::from(2), - U256::ZERO - ) - ); + 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, - ); - command.active_validators_length = committee.len() as u32; - command.liveness_slashing = liveness; - assert_eq!(harness.initialize_with(command), ExitCode::Ok); - - harness.set_caller(SYSTEM_CALLER); + harness.set_caller(owner); assert_eq!( - harness - .call(encode_args_call( - SIG_COMMIT_EPOCH_COMMITTEE, - &(committee.clone(),), - )) - .0, + harness.initialize(owner, vec![validator], vec![ten_tokens], 1_000), 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(liveness); - for validator in &committee[..2] { - assert_eq!( - harness - .call(encode_call( - SIG_SLASH, - &AddressCommand { value: *validator }, - )) - .0, - ExitCode::Ok - ); - } - assert_eq!( - staking_storage() - .active_validators_accessor() - .len_checked(&harness.sdk), - Ok(6) - ); + 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_call( - SIG_SLASH, - &AddressCommand { - value: committee[2], - }, - )) - .0, - ExitCode::Ok - ); - 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" + decode_output::(&output), + U256::from(9) * DEFAULT_MIN_STAKING_AMOUNT / U256::from(2) ); - let non_committee = validators[7]; - assert_eq!( - harness - .call(encode_call( - SIG_SLASH, - &AddressCommand { - value: non_committee, - }, - )) - .0, - ExitCode::Ok - ); - 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" - ); + 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(5) + decode_output::<(U256, U256)>(&output), + ( + U256::from(9) * DEFAULT_MIN_STAKING_AMOUNT / U256::from(2), + U256::ZERO + ) ); } #[test] -fn jail_readmission_rejects_validator_below_minimum_self_stake() { +fn committee_commit_is_system_gated_and_returns_epoch_stakes() { 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 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); - let mut command = harness.initialize_command( - owner, - validators.clone(), - vec![DEFAULT_MIN_VALIDATOR_STAKE; validators.len()], - 0, + harness.set_caller(owner); + assert_eq!( + harness.initialize( + owner, + vec![validator_a, validator_b], + vec![stake_a, stake_b], + 500, + ), + ExitCode::Ok ); - command.liveness_slashing = liveness; - assert_eq!(harness.initialize_with(command), ExitCode::Ok); - harness.set_caller(liveness); + let committee = (vec![validator_a, validator_b],); assert_eq!( harness - .call(encode_call(SIG_SLASH, &AddressCommand { value: validator },)) + .call(encode_args_call(SIG_COMMIT_EPOCH_COMMITTEE, &committee)) .0, - ExitCode::Ok - ); - staking::undelegate_from( - &mut harness.sdk, - validator, - validator, - DEFAULT_MIN_VALIDATOR_STAKE, - ) - .unwrap(); - harness.set_block_number(1_200); - - harness.set_caller(validator); - assert_revert_selector( - harness.call(encode_call( - SIG_RELEASE_VALIDATOR_FROM_JAIL, - &AddressCommand { value: validator }, - )), - ERR_OWNER_SELF_STAKE_BELOW_MINIMUM, + ExitCode::Panic ); - - harness.set_caller(liveness); + harness.set_caller(SYSTEM_CALLER); assert_eq!( harness - .call(encode_call( - SIG_READMIT_EXPIRED_JAILS, - &U64Command { value: 1 }, - )) + .call(encode_args_call(SIG_COMMIT_EPOCH_COMMITTEE, &committee)) .0, - ExitCode::Ok, - "automated readmission must skip an ineligible validator without blocking the scan" + ExitCode::Ok ); + + let (_, output) = harness.call(encode_call( + SIG_GET_EPOCH_COMMITTEE, + &U64Command { value: 0 }, + )); assert_eq!( - staking_storage() - .validators_accessor() - .entry(validator) - .status_accessor() - .get_checked(&harness.sdk) - .unwrap(), - STATUS_JAIL + 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!( + 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 chain_config_guards_match_solidity_boundaries() { +fn committee_verify_matches_the_selection_view_the_deriver_reads() { let owner = Address::with_last_byte(0xa0); - let mut harness = Harness::new(0); - harness.set_caller(owner); - assert_eq!( - harness.initialize(owner, Vec::new(), Vec::new(), 0), - ExitCode::Ok + 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, ); - chain_config_storage() - .dpos_activation_block_accessor() - .set_checked(&mut harness.sdk, 400) + 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(GENESIS_GOVERNANCE); - assert_revert_selector( - harness.call(encode_call( - SIG_SET_ACTIVE_VALIDATORS_LENGTH, - &U32Command { value: 52 }, - )), - ERR_MAX_ACTIVE_VALIDATORS_EXCEEDED, - ); - assert_revert_selector( - harness.call(encode_call( - SIG_SET_SLASH_REPORTER_REWARD_BPS, - &U32Command { value: 0 }, - )), - 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, - &U256Command { - value: MAX_BLEND_STIPEND_PER_EPOCH + U256::from(1), - }, - )), - ERR_BLEND_STIPEND_PER_EPOCH_TOO_HIGH, + 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_revert_selector( - harness.call(encode_call( - SIG_SET_DPOS_ACTIVATION_BLOCK, - &U64Command { value: 401 }, - )), - ERR_UNALIGNED_ACTIVATION_BLOCK, + assert!( + view_keys[0].bls_pubkey.is_empty(), + "the keyless top-ranked validator is surfaced with blank keys, not omitted" ); - chain_config_storage() - .min_undelegate_blocks_accessor() - .set_checked(&mut harness.sdk, U256::from(1_000)) - .unwrap(); + harness.set_caller(SYSTEM_CALLER); assert_revert_selector( - harness.call(encode_call( - SIG_SET_UNDELEGATE_PERIOD, - &U32Command { value: 4 }, + harness.call(encode_args_call( + SIG_COMMIT_EPOCH_COMMITTEE, + &(vec![keyed_a, keyed_b],), )), - ERR_UNDELEGATE_WINDOW_TOO_SHORT, - ); - assert_eq!( - harness - .call(encode_call( - SIG_SET_DPOS_ACTIVATION_BLOCK, - &U64Command { value: 1_200 }, - )) - .0, - ExitCode::Ok + ERR_COMMITTEE_LENGTH_MISMATCH, ); assert_eq!( harness - .call(encode_call( - SIG_SET_EPOCH_BLOCK_INTERVAL, - &U32Command { value: 300 }, + .call(encode_args_call( + SIG_COMMIT_EPOCH_COMMITTEE, + &(vec![keyed_a],), )) .0, ExitCode::Ok ); - harness.set_block_number(1_200); - assert_revert_selector( - harness.call(encode_call( - SIG_SET_EPOCH_BLOCK_INTERVAL, - &U32Command { value: 200 }, - )), - ERR_DPOS_ALREADY_ACTIVE, - ); - assert_revert_selector( - harness.call(encode_call( - SIG_SET_UNDELEGATE_PERIOD, - &U32Command { value: 10 }, - )), - ERR_DPOS_ALREADY_ACTIVE, - ); + let (_, output) = harness.call(encode_call( + SIG_GET_EPOCH_COMMITTEE, + &U64Command { value: 0 }, + )); + assert_eq!(decode_output::>(&output), vec![keyed_a]); } #[test] -fn dpos_activation_at_block_zero_remains_configurable() { +fn fully_keyless_committee_reverts_without_advancing_commit_pointer() { let owner = Address::with_last_byte(0xa0); - let mut harness = Harness::new(0); + 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, Vec::new(), Vec::new(), 0), + harness.initialize( + owner, + validators.clone(), + vec![DEFAULT_MIN_VALIDATOR_STAKE; validators.len()], + 500, + ), ExitCode::Ok ); - harness.set_caller(GENESIS_GOVERNANCE); - chain_config_storage() - .min_undelegate_blocks_accessor() - .set_checked(&mut harness.sdk, U256::from(1_000)) - .unwrap(); + 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_SET_EPOCH_BLOCK_INTERVAL, - &U32Command { value: 100 }, + harness.call(encode_args_call( + SIG_COMMIT_EPOCH_COMMITTEE, + &(Vec::
::new(),), )), - ERR_UNDELEGATE_WINDOW_TOO_SHORT, - ); - assert_eq!( - harness - .call(encode_call( - SIG_SET_EPOCH_BLOCK_INTERVAL, - &U32Command { value: 250 }, - )) - .0, - ExitCode::Ok + 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); + + 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_SET_DPOS_ACTIVATION_BLOCK, - &U64Command { value: 500 }, + .call(encode_args_call( + SIG_COMMIT_EPOCH_COMMITTEE, + &(validators.clone(),), )) .0, ExitCode::Ok ); + let (_, output) = harness.call(encode_empty_call(SIG_NEXT_EPOCH_TO_COMMIT)); + assert_eq!(decode_output::(&output), 1); } #[test] -fn undelegate_period_change_does_not_shorten_queued_self_stake_lock() { - let sponsor = Address::with_last_byte(0xa0); - let validator = Address::with_last_byte(0x01); - let stake = DEFAULT_MIN_VALIDATOR_STAKE; - let mut harness = Harness::new(0); +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(sponsor, vec![validator], vec![stake], 0), - ExitCode::Ok - ); - - staking::undelegate_from(&mut harness.sdk, validator, validator, stake).unwrap(); - let (_, output) = harness.call(encode_call( - SIG_GET_VALIDATOR_SELF_STAKE_LOCK, - &AddressCommand { value: validator }, - )); - let before = decode_output::<(bool, u64)>(&output); - assert!(before.0); - - harness.set_caller(GENESIS_GOVERNANCE); - assert_eq!( - harness - .call(encode_call( - SIG_SET_UNDELEGATE_PERIOD, - &U32Command { value: 1 }, - )) - .0, + harness.initialize( + owner, + vec![first, second], + vec![DEFAULT_MIN_VALIDATOR_STAKE, DEFAULT_MIN_VALIDATOR_STAKE], + 0, + ), ExitCode::Ok ); - let (_, output) = harness.call(encode_call( - SIG_GET_VALIDATOR_SELF_STAKE_LOCK, - &AddressCommand { value: validator }, - )); - assert_eq!(decode_output::<(bool, u64)>(&output), before); + 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 lifecycle_transitions_preserve_next_epoch_snapshot_frontier() { - let contract_owner = Address::with_last_byte(0xa0); +fn selection_filters_active_validator_below_current_minimum() { + 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(1_000); - harness.set_caller(contract_owner); assert_eq!( - harness.initialize(contract_owner, vec![validator], vec![stake], 500), + 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(); - harness.set_caller(GENESIS_GOVERNANCE); - assert_eq!( - harness - .call(encode_call( - SIG_DISABLE_VALIDATOR, - &AddressCommand { value: validator }, - )) - .0, - ExitCode::Ok - ); - let record = staking_storage().validators_accessor().entry(validator); - assert_eq!( - record.status_accessor().get_checked(&harness.sdk).unwrap(), - STATUS_PENDING - ); - assert_eq!( - record - .changed_at_accessor() - .get_checked(&harness.sdk) - .unwrap(), - 1 - ); assert_eq!( staking_storage() - .validator_snapshots_accessor() + .validators_accessor() .entry(validator) - .entry(1) - .total_delegated_accessor() + .status_accessor() .get_checked(&harness.sdk) .unwrap(), - math::compact_balance(stake).unwrap() + 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_call( - SIG_ACTIVATE_VALIDATOR, - &AddressCommand { value: validator }, + .call(encode_args_call( + SIG_COMMIT_EPOCH_COMMITTEE, + &(vec![validator],), )) .0, ExitCode::Ok ); - assert_eq!( - record.status_accessor().get_checked(&harness.sdk).unwrap(), - STATUS_ACTIVE - ); + assert!(consensus_storage() + .dkg_qual_accessor() + .entry(1) + .get_checked(&harness.sdk) + .unwrap()); } #[test] -fn validator_owner_cannot_drop_below_minimum_while_delegators_remain() { +fn chain_config_guards_match_solidity_boundaries() { let owner = Address::with_last_byte(0xa0); - let delegator = Address::with_last_byte(0xb0); - let validator = Address::with_last_byte(0x01); - let stake = DEFAULT_MIN_VALIDATOR_STAKE * U256::from(10); - let mut harness = Harness::new(1_000); + let mut harness = Harness::new(0); harness.set_caller(owner); assert_eq!( - harness.initialize(owner, vec![validator], vec![stake], 500), + harness.initialize(owner, Vec::new(), Vec::new(), 0), ExitCode::Ok ); - staking::delegate_to( - &mut harness.sdk, - delegator, - validator, - DEFAULT_MIN_STAKING_AMOUNT * U256::from(2), - false, - ) - .unwrap(); + chain_config_storage() + .dpos_activation_block_accessor() + .set_checked(&mut harness.sdk, 400) + .unwrap(); + harness.set_caller(GENESIS_GOVERNANCE); - let before = harness.sdk.dump_storage(); assert_revert_selector( - ( - staking::undelegate_from(&mut harness.sdk, validator, validator, stake).unwrap_err(), - harness.sdk.take_output(), - ), - ERR_OWNER_SELF_STAKE_BELOW_MINIMUM, + harness.call(encode_call( + SIG_SET_ACTIVE_VALIDATORS_LENGTH, + &U32Command { value: 52 }, + )), + ERR_MAX_ACTIVE_VALIDATORS_EXCEEDED, + ); + assert_revert_selector( + harness.call(encode_call( + SIG_SET_SLASH_REPORTER_REWARD_BPS, + &U32Command { value: 0 }, + )), + ERR_ZERO_VALUE, + ); + assert_revert_selector( + harness.call(encode_call( + SIG_SET_BLEND_STIPEND_PER_EPOCH, + &U256Command { + value: MAX_BLEND_STIPEND_PER_EPOCH + U256::from(1), + }, + )), + ERR_BLEND_STIPEND_PER_EPOCH_TOO_HIGH, + ); + assert_revert_selector( + harness.call(encode_call( + SIG_SET_DPOS_ACTIVATION_BLOCK, + &U64Command { value: 401 }, + )), + ERR_UNALIGNED_ACTIVATION_BLOCK, ); - harness.sdk.restore_storage(before); - let withdrawn = stake / U256::from(2); - staking::undelegate_from(&mut harness.sdk, validator, validator, withdrawn).unwrap(); + chain_config_storage() + .min_undelegate_blocks_accessor() + .set_checked(&mut harness.sdk, U256::from(1_000)) + .unwrap(); + assert_revert_selector( + harness.call(encode_call( + SIG_SET_UNDELEGATE_PERIOD, + &U32Command { value: 4 }, + )), + ERR_UNDELEGATE_WINDOW_TOO_SHORT, + ); assert_eq!( - staking::validator_total_at(&harness.sdk, validator, 1).unwrap(), - stake - withdrawn + harness + .call(encode_call( + SIG_SET_DPOS_ACTIVATION_BLOCK, + &U64Command { value: 1_200 }, + )) + .0, + ExitCode::Ok ); assert_eq!( - staking::validator_total_at(&harness.sdk, validator, 2).unwrap(), - stake - withdrawn + DEFAULT_MIN_STAKING_AMOUNT * U256::from(2), - "the earlier owner withdrawal must carry into the future delegation snapshot" + harness + .call(encode_call( + SIG_SET_EPOCH_BLOCK_INTERVAL, + &U32Command { value: 300 }, + )) + .0, + ExitCode::Ok + ); + harness.set_block_number(1_200); + assert_revert_selector( + harness.call(encode_call( + SIG_SET_EPOCH_BLOCK_INTERVAL, + &U32Command { value: 200 }, + )), + ERR_DPOS_ALREADY_ACTIVE, + ); + assert_revert_selector( + harness.call(encode_call( + SIG_SET_UNDELEGATE_PERIOD, + &U32Command { value: 10 }, + )), + ERR_DPOS_ALREADY_ACTIVE, ); } #[test] -fn sole_validator_owner_full_exit_deactivates_without_leaving_subminimum_dust() { +fn dpos_activation_at_block_zero_remains_configurable() { let owner = Address::with_last_byte(0xa0); - let validator = Address::with_last_byte(0x01); - let stake = DEFAULT_MIN_VALIDATOR_STAKE * U256::from(10); - let mut harness = Harness::new(1_000); + let mut harness = Harness::new(0); assert_eq!( - harness.initialize(owner, vec![validator], vec![stake], 500), + harness.initialize(owner, Vec::new(), Vec::new(), 0), ExitCode::Ok ); - - let dust = DEFAULT_MIN_VALIDATOR_STAKE / U256::from(2); - let before = harness.sdk.dump_storage(); + harness.set_caller(GENESIS_GOVERNANCE); + chain_config_storage() + .min_undelegate_blocks_accessor() + .set_checked(&mut harness.sdk, U256::from(1_000)) + .unwrap(); assert_revert_selector( - ( - staking::undelegate_from(&mut harness.sdk, validator, validator, stake - dust) - .unwrap_err(), - harness.sdk.take_output(), - ), - ERR_OWNER_SELF_STAKE_BELOW_MINIMUM, + harness.call(encode_call( + SIG_SET_EPOCH_BLOCK_INTERVAL, + &U32Command { value: 100 }, + )), + ERR_UNDELEGATE_WINDOW_TOO_SHORT, ); - harness.sdk.restore_storage(before); - staking::undelegate_from(&mut harness.sdk, validator, validator, stake).unwrap(); + assert_eq!( + harness + .call(encode_call( + SIG_SET_EPOCH_BLOCK_INTERVAL, + &U32Command { value: 250 }, + )) + .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( + SIG_SET_DPOS_ACTIVATION_BLOCK, + &U64Command { value: 500 }, + )) + .0, + ExitCode::Ok + ); +} + +#[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); + let validator = Address::with_last_byte(0x01); + let stake = DEFAULT_MIN_VALIDATOR_STAKE; + let mut harness = Harness::new(0); + assert_eq!( + harness.initialize(sponsor, vec![validator], vec![stake], 0), + ExitCode::Ok + ); + + staking::undelegate_from(&mut harness.sdk, validator, validator, stake).unwrap(); + let (_, output) = harness.call(encode_call( + SIG_GET_VALIDATOR_SELF_STAKE_LOCK, + &AddressCommand { value: validator }, + )); + let before = decode_output::<(bool, u64)>(&output); + assert!(before.0); + + harness.set_caller(GENESIS_GOVERNANCE); + assert_eq!( + harness + .call(encode_call( + SIG_SET_UNDELEGATE_PERIOD, + &U32Command { value: 1 }, + )) + .0, + ExitCode::Ok + ); + let (_, output) = harness.call(encode_call( + SIG_GET_VALIDATOR_SELF_STAKE_LOCK, + &AddressCommand { value: validator }, + )); + assert_eq!(decode_output::<(bool, u64)>(&output), before); +} + +#[test] +fn lifecycle_transitions_preserve_next_epoch_snapshot_frontier() { + let contract_owner = Address::with_last_byte(0xa0); + let validator = Address::with_last_byte(0x01); + let stake = DEFAULT_MIN_VALIDATOR_STAKE; + let mut harness = Harness::new(1_000); + harness.set_caller(contract_owner); + assert_eq!( + harness.initialize(contract_owner, vec![validator], vec![stake], 500), + ExitCode::Ok + ); + + harness.set_caller(GENESIS_GOVERNANCE); + assert_eq!( + harness + .call(encode_call( + SIG_DISABLE_VALIDATOR, + &AddressCommand { value: validator }, + )) + .0, + ExitCode::Ok + ); + let record = staking_storage().validators_accessor().entry(validator); + assert_eq!( + record.status_accessor().get_checked(&harness.sdk).unwrap(), + STATUS_PENDING + ); + assert_eq!( + record + .changed_at_accessor() + .get_checked(&harness.sdk) + .unwrap(), + 1 + ); + assert_eq!( + staking_storage() + .validator_snapshots_accessor() + .entry(validator) + .entry(1) + .total_delegated_accessor() + .get_checked(&harness.sdk) + .unwrap(), + math::compact_balance(stake).unwrap() + ); + + assert_eq!( + harness + .call(encode_call( + SIG_ACTIVATE_VALIDATOR, + &AddressCommand { value: validator }, + )) + .0, + ExitCode::Ok + ); + assert_eq!( + record.status_accessor().get_checked(&harness.sdk).unwrap(), + STATUS_ACTIVE + ); +} + +#[test] +fn validator_owner_cannot_drop_below_minimum_while_delegators_remain() { + let owner = Address::with_last_byte(0xa0); + let delegator = Address::with_last_byte(0xb0); + let validator = Address::with_last_byte(0x01); + let stake = DEFAULT_MIN_VALIDATOR_STAKE * U256::from(10); + let mut harness = Harness::new(1_000); + harness.set_caller(owner); + assert_eq!( + harness.initialize(owner, vec![validator], vec![stake], 500), + ExitCode::Ok + ); + staking::delegate_to( + &mut harness.sdk, + delegator, + validator, + DEFAULT_MIN_STAKING_AMOUNT * U256::from(2), + false, + ) + .unwrap(); + + let before = harness.sdk.dump_storage(); + assert_revert_selector( + ( + staking::undelegate_from(&mut harness.sdk, validator, validator, stake).unwrap_err(), + harness.sdk.take_output(), + ), + ERR_OWNER_SELF_STAKE_BELOW_MINIMUM, + ); + harness.sdk.restore_storage(before); + + let withdrawn = stake / U256::from(2); + staking::undelegate_from(&mut harness.sdk, validator, validator, withdrawn).unwrap(); + assert_eq!( + staking::validator_total_at(&harness.sdk, validator, 1).unwrap(), + stake - withdrawn + ); + assert_eq!( + staking::validator_total_at(&harness.sdk, validator, 2).unwrap(), + stake - withdrawn + DEFAULT_MIN_STAKING_AMOUNT * U256::from(2), + "the earlier owner withdrawal must carry into the future delegation snapshot" + ); +} + +#[test] +fn sole_validator_owner_full_exit_deactivates_without_leaving_subminimum_dust() { + let owner = Address::with_last_byte(0xa0); + let validator = Address::with_last_byte(0x01); + let stake = DEFAULT_MIN_VALIDATOR_STAKE * U256::from(10); + let mut harness = Harness::new(1_000); + assert_eq!( + harness.initialize(owner, vec![validator], vec![stake], 500), + ExitCode::Ok + ); + + let dust = DEFAULT_MIN_VALIDATOR_STAKE / U256::from(2); + let before = harness.sdk.dump_storage(); + assert_revert_selector( + ( + staking::undelegate_from(&mut harness.sdk, validator, validator, stake - dust) + .unwrap_err(), + harness.sdk.take_output(), + ), + ERR_OWNER_SELF_STAKE_BELOW_MINIMUM, + ); + harness.sdk.restore_storage(before); + staking::undelegate_from(&mut harness.sdk, validator, validator, stake).unwrap(); let storage = staking_storage(); assert_eq!( @@ -3298,443 +3598,856 @@ fn zero_reserve_balance_skips_without_disbursement_call() { assert_stipend_events(&harness.sdk, 0, U256::ZERO, true); } +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), + } + }); +} + +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 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 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 }, - )) - .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 }, + .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) - ); - assert_eq!(calls.borrow().disburse_calls.len(), 2); - assert_stipend_events(&harness.sdk, 0, assigned, false); } -} + // 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(); -#[test] -fn permissionless_validator_claim_waits_for_stipend_settlement() { - let assigned = U256::from(100); - let attacker = Address::with_last_byte(0xd0); - let token = Address::with_last_byte(0xf0); - let (mut harness, _calls, validator) = - stipend_test_sdk(vec![assigned], vec![MockDisbursement::Amount(assigned)]); + chain_config_storage() + .blend_stipend_per_epoch_accessor() + .set_checked(&mut harness.sdk, U256::from(100)) + .unwrap(); staking_storage() - .validator_snapshots_accessor() - .entry(validator) - .entry(0) - .commission_rate_accessor() - .set_checked(&mut harness.sdk, 1_000) + .last_rewarded_epoch_p1_accessor() + .set_checked(&mut harness.sdk, 2) .unwrap(); - harness.set_block_number(1_000 + DEFAULT_EPOCH_BLOCK_INTERVAL); + install_solvent_reserve(&harness.sdk, reserve, U256::from(100)); + record_test_production(&mut harness.sdk, 2, DEFAULT_EPOCH_BLOCK_INTERVAL as u32); - harness.set_caller(attacker); + harness.set_block_number(1_000 + 3 * DEFAULT_EPOCH_BLOCK_INTERVAL); + harness.set_caller(SYSTEM_CALLER); assert_eq!( harness .call(encode_call( - SIG_CLAIM_VALIDATOR_FEE, - &AddressCommand { value: validator }, + SIG_SETTLE_EPOCH_STIPEND, + &U64Command { value: 2 }, )) .0, ExitCode::Ok ); - assert_eq!( - staking_storage() - .validators_accessor() - .entry(validator) - .claimed_at_accessor() - .get_checked(&harness.sdk) - .unwrap(), - 0, - "an unsettled epoch must remain claimable after a permissionless call" - ); + 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: 0 }, + &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(), - 1 + 0, + "the cursor must not advance past an epoch that was never paid" ); - 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) - }); - harness.set_caller(attacker); + harness.set_block_number(1_000 + DEFAULT_EPOCH_BLOCK_INTERVAL); + harness.set_caller(SYSTEM_CALLER); assert_eq!( harness .call(encode_call( - SIG_CLAIM_VALIDATOR_FEE, - &AddressCommand { value: validator }, + SIG_SETTLE_EPOCH_STIPEND, + &U64Command { value: 1 }, )) .0, ExitCode::Ok ); + assert_eq!(epoch_reward(&harness.sdk, validator, 0), U256::from(100)); assert_eq!( - transfers.borrow().as_slice(), - &[(validator, U256::from(10))] - ); - assert_eq!( - staking_storage() - .validators_accessor() - .entry(validator) - .claimed_at_accessor() - .get_checked(&harness.sdk) - .unwrap(), - 1 + 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 delayed_reward_settlement_does_not_block_matured_principal() { +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 delegator = Address::with_last_byte(0x02); - let token = Address::with_last_byte(0xf0); + let reserve = Address::with_last_byte(0xc0); let stake = DEFAULT_MIN_VALIDATOR_STAKE; - let reward = DEFAULT_MIN_STAKING_AMOUNT; - 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], 0), - ExitCode::Ok - ); - // Keep this regression focused on the independent reward/principal cursors. - // Validator-owner principal is deliberately subject to the separate - // self-stake liability deadline covered by the bounded-lock tests. - staking::delegate_to(&mut harness.sdk, delegator, validator, stake, false).unwrap(); - harness.set_block_number(activation_block + DEFAULT_EPOCH_BLOCK_INTERVAL * WARMUP_DELAY); - staking::undelegate_from(&mut harness.sdk, delegator, validator, stake).unwrap(); - let delegation = staking_storage() - .validator_delegations_accessor() - .entry(validator) - .entry(delegator); + 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); - 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) - }); + 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_caller(delegator); - let maturity_epoch = WARMUP_DELAY + 1 + DEFAULT_UNDELEGATE_PERIOD; - harness.set_block_number(activation_block + DEFAULT_EPOCH_BLOCK_INTERVAL * maturity_epoch); + harness.set_block_number(1_000 + 3 * DEFAULT_EPOCH_BLOCK_INTERVAL); + harness.set_caller(SYSTEM_CALLER); assert_eq!( harness .call(encode_call( - SIG_CLAIM_DELEGATOR_FEE, - &AddressCommand { value: validator }, + SIG_SETTLE_EPOCH_STIPEND, + &U64Command { value: 2 }, )) .0, ExitCode::Ok ); - assert_eq!(transfers.borrow().as_slice(), &[(delegator, stake)]); + + assert_eq!(epoch_reward(&harness.sdk, validator, 0), U256::ZERO); + assert_eq!(epoch_reward(&harness.sdk, validator, 1), U256::ZERO); assert_eq!( - delegation - .delegate_gap_accessor() - .get_checked(&harness.sdk) - .unwrap(), - 0, - "principal maturity must not consume the unsettled reward cursor" + epoch_reward(&harness.sdk, validator, 2), + U256::from(100), + "only the epoch that recorded blocks is paid" ); assert_eq!( - delegation - .undelegate_gap_accessor() + staking_storage() + .credited_blend_accessor() .get_checked(&harness.sdk) .unwrap(), - 1 + U256::from(100), + "the skipped epochs must not have drawn a pot each" ); +} - staking_storage() - .validator_snapshots_accessor() - .entry(validator) - .entry(WARMUP_DELAY) - .total_blend_rewards_accessor() - .set_checked( - &mut harness.sdk, - math::narrow_reward(reward * U256::from(2)).expect("reward fits uint96"), - ) - .unwrap(); - staking_storage() - .last_rewarded_epoch_p1_accessor() - .set_checked(&mut harness.sdk, WARMUP_DELAY + 1) +// 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_CLAIM_DELEGATOR_FEE, - &AddressCommand { value: validator }, + 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!( - transfers.borrow().as_slice(), - &[(delegator, stake), (delegator, reward)] + harness + .call(encode_call( + SIG_SETTLE_EPOCH_STIPEND, + &U64Command { value: 0 }, + )) + .0, + ExitCode::Ok ); assert_eq!( - delegation - .delegate_gap_accessor() - .get_checked(&harness.sdk) - .unwrap(), - 1 + 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 reward_claims_are_bounded_to_one_thousand_epochs() { +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(0); - harness.set_caller(owner); + let mut harness = Harness::new(1_000); assert_eq!( - harness.initialize( - owner, - vec![validator], - vec![DEFAULT_MIN_VALIDATOR_STAKE], - 500, - ), + harness.initialize(owner, vec![validator], vec![DEFAULT_MIN_VALIDATOR_STAKE], 0), ExitCode::Ok ); - harness.set_block_number(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) + chain_config_storage() + .blend_stipend_per_epoch_accessor() + .set_checked(&mut harness.sdk, U256::from(250)) .unwrap(); - harness.set_caller(validator); - assert_eq!( - harness - .call(encode_call( - SIG_CLAIM_DELEGATOR_FEE, - &AddressCommand { value: validator }, - )) - .0, - ExitCode::Ok + 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!( - staking_storage() - .validator_delegations_accessor() - .entry(validator) - .entry(validator) - .delegate_queue_accessor() - .at(0) - .epoch_accessor() - .get_checked(&harness.sdk) - .unwrap(), - MAX_EPOCHS_PER_CLAIM + 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 - .call(encode_call( - SIG_CLAIM_VALIDATOR_FEE, - &AddressCommand { value: validator }, - )) - .0, + 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() - .validators_accessor() - .entry(validator) - .claimed_at_accessor() + .last_rewarded_epoch_p1_accessor() .get_checked(&harness.sdk) .unwrap(), - MAX_EPOCHS_PER_CLAIM + 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); + 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_CLAIM_VALIDATOR_FEE_AT_EPOCH, - &ValidatorEpochCommand { - validator, - before_epoch: MAX_EPOCHS_PER_CLAIM + 2, - }, + SIG_SETTLE_EPOCH_STIPEND, + &U64Command { value: 0 }, )), - ERR_INVALID_CLAIM_EPOCH, + 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 liveness_halt_guard_and_equivocation_tombstone_are_enforced() { +fn tombstoned_committee_member_earns_no_stipend_share() { 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 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); - 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); + 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(liveness); - for validator in &validators[..2] { + + 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_SLASH, - &AddressCommand { value: *validator }, + 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); } +} + +#[test] +fn permissionless_validator_claim_waits_for_stipend_settlement() { + let assigned = U256::from(100); + let attacker = Address::with_last_byte(0xd0); + let token = Address::with_last_byte(0xf0); + let (mut harness, _calls, validator) = + stipend_test_sdk(vec![assigned], vec![MockDisbursement::Amount(assigned)]); + staking_storage() + .validator_snapshots_accessor() + .entry(validator) + .entry(0) + .commission_rate_accessor() + .set_checked(&mut harness.sdk, 1_000) + .unwrap(); + harness.set_block_number(1_000 + DEFAULT_EPOCH_BLOCK_INTERVAL); + + harness.set_caller(attacker); assert_eq!( - staking_storage() - .validators_accessor() - .entry(validators[0]) - .status_accessor() - .get_checked(&harness.sdk) - .unwrap(), - STATUS_JAIL + harness + .call(encode_call( + SIG_CLAIM_VALIDATOR_FEE, + &AddressCommand { value: validator }, + )) + .0, + ExitCode::Ok ); assert_eq!( staking_storage() .validators_accessor() - .entry(validators[1]) - .status_accessor() + .entry(validator) + .claimed_at_accessor() .get_checked(&harness.sdk) .unwrap(), - STATUS_ACTIVE, - "the second jail would drop the active set below Simplex quorum" + 0, + "an unsettled epoch must remain claimable after a permissionless call" ); - 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); + harness.set_caller(SYSTEM_CALLER); assert_eq!( harness .call(encode_call( - SIG_SLASH, - &AddressCommand { - value: validators[0], - }, + SIG_SETTLE_EPOCH_STIPEND, + &U64Command { value: 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" + assert_eq!( + staking_storage() + .last_rewarded_epoch_p1_accessor() + .get_checked(&harness.sdk) + .unwrap(), + 1 ); - chain_config_storage() - .validator_jail_epoch_length_accessor() - .set_checked(&mut harness.sdk, 0) - .unwrap(); - harness.set_block_number(1_300); + 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) + }); + harness.set_caller(attacker); assert_eq!( harness .call(encode_call( - SIG_SLASH, - &AddressCommand { - value: validators[0], - }, + SIG_CLAIM_VALIDATOR_FEE, + &AddressCommand { value: validator }, )) .0, ExitCode::Ok ); assert_eq!( - jailed_record - .jailed_before_accessor() + transfers.borrow().as_slice(), + &[(validator, U256::from(10))] + ); + assert_eq!( + staking_storage() + .validators_accessor() + .entry(validator) + .claimed_at_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, + 1 + ); +} + +#[test] +fn delayed_reward_settlement_does_not_block_matured_principal() { + let owner = Address::with_last_byte(0xa0); + let validator = Address::with_last_byte(0x01); + let delegator = Address::with_last_byte(0x02); + let token = Address::with_last_byte(0xf0); + let stake = DEFAULT_MIN_VALIDATOR_STAKE; + let reward = DEFAULT_MIN_STAKING_AMOUNT; + 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], 0), + ExitCode::Ok + ); + // Keep this regression focused on the independent reward/principal cursors. + // Validator-owner principal is deliberately subject to the separate + // self-stake liability deadline covered by the bounded-lock tests. + staking::delegate_to(&mut harness.sdk, delegator, validator, stake, false).unwrap(); + harness.set_block_number(activation_block + DEFAULT_EPOCH_BLOCK_INTERVAL * WARMUP_DELAY); + staking::undelegate_from(&mut harness.sdk, delegator, validator, stake).unwrap(); + let delegation = staking_storage() + .validator_delegations_accessor() + .entry(validator) + .entry(delegator); + + 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) + }); + + harness.set_caller(delegator); + let maturity_epoch = WARMUP_DELAY + 1 + DEFAULT_UNDELEGATE_PERIOD; + harness.set_block_number(activation_block + DEFAULT_EPOCH_BLOCK_INTERVAL * maturity_epoch); + assert_eq!( + harness + .call(encode_call( + SIG_CLAIM_DELEGATOR_FEE, + &AddressCommand { value: validator }, + )) + .0, + ExitCode::Ok + ); + assert_eq!(transfers.borrow().as_slice(), &[(delegator, stake)]); + assert_eq!( + delegation + .claimed_through_epoch_accessor() + .get_checked(&harness.sdk) + .unwrap(), + u64::from(WARMUP_DELAY), + "principal maturity must not advance the reward cursor past the settled frontier" + ); + assert_eq!( + delegation + .undelegate_gap_accessor() + .get_checked(&harness.sdk) + .unwrap(), + 1 + ); + + staking_storage() + .validator_snapshots_accessor() + .entry(validator) + .entry(WARMUP_DELAY) + .total_blend_rewards_accessor() + .set_checked( + &mut harness.sdk, + math::narrow_reward(reward * U256::from(2)).expect("reward fits uint96"), + ) + .unwrap(); + staking_storage() + .last_rewarded_epoch_p1_accessor() + .set_checked(&mut harness.sdk, WARMUP_DELAY + 1) + .unwrap(); + assert_eq!( + harness + .call(encode_call( + SIG_CLAIM_DELEGATOR_FEE, + &AddressCommand { value: validator }, + )) + .0, + ExitCode::Ok + ); + assert_eq!( + transfers.borrow().as_slice(), + &[(delegator, stake), (delegator, reward)] + ); + assert_eq!( + delegation + .claimed_through_epoch_accessor() + .get_checked(&harness.sdk) + .unwrap(), + 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" + ); +} + +#[test] +fn reward_claims_are_bounded_to_one_thousand_epochs() { + let owner = Address::with_last_byte(0xa0); + let validator = Address::with_last_byte(0x01); + // 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( + owner, + vec![validator], + vec![DEFAULT_MIN_VALIDATOR_STAKE], + 500, + ), + ExitCode::Ok + ); + 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) + .unwrap(); + harness.set_caller(validator); + assert_eq!( + harness + .call(encode_call( + SIG_CLAIM_DELEGATOR_FEE, + &AddressCommand { value: validator }, + )) + .0, + ExitCode::Ok + ); + let delegation = staking_storage() + .validator_delegations_accessor() + .entry(validator) + .entry(validator); + assert_eq!( + 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(), + 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 + .call(encode_call( + SIG_CLAIM_VALIDATOR_FEE, + &AddressCommand { value: validator }, + )) + .0, + ExitCode::Ok + ); + assert_eq!( + staking_storage() + .validators_accessor() + .entry(validator) + .claimed_at_accessor() + .get_checked(&harness.sdk) + .unwrap(), + MAX_EPOCHS_PER_CLAIM + ); + assert_revert_selector( + harness.call(encode_call( + SIG_CLAIM_VALIDATOR_FEE_AT_EPOCH, + &ValidatorEpochCommand { + validator, + before_epoch: MAX_EPOCHS_PER_CLAIM + 2, + }, + )), + ERR_INVALID_CLAIM_EPOCH, ); } @@ -4174,337 +4887,1675 @@ fn equivocation_commit_reveal_prevents_copied_reveal_reward_redirection() { ); } -#[test] -fn equivocation_seizes_active_and_pending_self_delegation() { +#[test] +fn equivocation_seizes_active_and_pending_self_delegation() { + let owner = Address::with_last_byte(0xa0); + let validator = Address::with_last_byte(0x01); + let reporter = Address::with_last_byte(0xb0); + let token = Address::with_last_byte(0xc0); + let active_stake = DEFAULT_MIN_VALIDATOR_STAKE; + let pending_operation = DEFAULT_MIN_VALIDATOR_STAKE; + let pending_stake = pending_operation * U256::from(2); + let total_stake = active_stake + pending_stake; + let mut harness = Harness::new(1_000); + harness.set_caller(owner); + assert_eq!( + harness.initialize(owner, vec![validator], vec![total_stake], 500), + ExitCode::Ok + ); + chain_config_storage() + .staking_token_accessor() + .set_checked(&mut harness.sdk, token) + .unwrap(); + + for _ in 0..2 { + staking::undelegate_from(&mut harness.sdk, validator, validator, pending_operation) + .unwrap(); + } + let delegation = staking_storage() + .validator_delegations_accessor() + .entry(validator) + .entry(validator); + let delegates = delegation.delegate_queue_accessor(); + let undelegates = delegation.undelegate_queue_accessor(); + assert_eq!( + delegates + .at(delegates.len_checked(&harness.sdk).unwrap() - 1) + .amount_accessor() + .get_checked(&harness.sdk) + .unwrap(), + math::compact_balance(active_stake).unwrap() + ); + assert_eq!(undelegates.len_checked(&harness.sdk).unwrap(), 2); + assert_eq!( + delegation + .pending_undelegated_accessor() + .get_checked(&harness.sdk) + .unwrap(), + pending_stake + ); + + 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) + }); + + consensus::seize_self_stake(&mut harness.sdk, validator, validator, reporter).unwrap(); + + let reporter_reward = + total_stake * U256::from(DEFAULT_SLASH_REPORTER_REWARD_BPS) / U256::from(10_000); + assert_eq!( + transfers.borrow().as_slice(), + &[ + (reporter, reporter_reward), + (EQUIVOCATION_BURN_SINK, total_stake - reporter_reward), + ] + ); + assert_eq!(delegates.len_checked(&harness.sdk).unwrap(), 0); + assert_eq!(undelegates.len_checked(&harness.sdk).unwrap(), 0); + assert_eq!( + delegation + .pending_undelegated_accessor() + .get_checked(&harness.sdk) + .unwrap(), + U256::ZERO + ); + assert_eq!( + delegation + .claimed_through_epoch_accessor() + .get_checked(&harness.sdk) + .unwrap(), + 0, + "seizure resets the reward cursor with the queues" + ); + assert_eq!( + delegation + .undelegate_gap_accessor() + .get_checked(&harness.sdk) + .unwrap(), + 0 + ); + + harness.set_block_number(10_000); + harness.set_caller(validator); + assert_eq!( + harness + .call(encode_call( + SIG_CLAIM_DELEGATOR_FEE, + &AddressCommand { value: validator }, + )) + .0, + ExitCode::Ok + ); + 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 validator = Address::with_last_byte(0x01); - let reporter = Address::with_last_byte(0xb0); - let token = Address::with_last_byte(0xc0); - let active_stake = DEFAULT_MIN_VALIDATOR_STAKE; - let pending_operation = DEFAULT_MIN_VALIDATOR_STAKE; - let pending_stake = pending_operation * U256::from(2); - let total_stake = active_stake + pending_stake; + let members: Vec
= (1..=stakes.len()) + .map(|index| Address::with_last_byte(index as u8)) + .collect(); let mut harness = Harness::new(1_000); - harness.set_caller(owner); + 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.initialize(owner, vec![validator], vec![total_stake], 500), + harness + .call(encode_call( + SIG_SET_PRODUCTION_LIVENESS_DISABLED, + &BoolCommand { value: false }, + )) + .0, ExitCode::Ok ); - chain_config_storage() - .staking_token_accessor() - .set_checked(&mut harness.sdk, token) - .unwrap(); + harness.set_caller(SYSTEM_CALLER); + harness.sdk.take_logs(); + (harness, members) +} - for _ in 0..2 { - staking::undelegate_from(&mut harness.sdk, validator, validator, pending_operation) +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(); } - let delegation = staking_storage() - .validator_delegations_accessor() - .entry(validator) + storage + .blocks_in_epoch_accessor() + .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`. +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); - let delegates = delegation.delegate_queue_accessor(); - let undelegates = delegation.undelegate_queue_accessor(); + ( + 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!( - delegates - .at(delegates.len_checked(&harness.sdk).unwrap() - 1) - .amount_accessor() + storage + .blocks_in_epoch_accessor() + .entry(0u64) .get_checked(&harness.sdk) .unwrap(), - math::compact_balance(active_stake).unwrap() + 1, + "a replayed block number must be counted exactly once" ); - assert_eq!(undelegates.len_checked(&harness.sdk).unwrap(), 2); + assert_eq!(production_record(&harness.sdk, members[0]).0, 1); assert_eq!( - delegation - .pending_undelegated_accessor() + storage + .last_processed_block_accessor() .get_checked(&harness.sdk) .unwrap(), - pending_stake + 1_000 ); - 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) - }); + 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) + ); +} - consensus::seize_self_stake(&mut harness.sdk, validator, validator, reporter).unwrap(); +// 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); - let reporter_reward = - total_stake * U256::from(DEFAULT_SLASH_REPORTER_REWARD_BPS) / U256::from(10_000); + assert_eq!(close_epoch_via_record(&mut harness, 0), ExitCode::Ok); + + let storage = production_liveness_storage(); assert_eq!( - transfers.borrow().as_slice(), - &[ - (reporter, reporter_reward), - (EQUIVOCATION_BURN_SINK, total_stake - reporter_reward), - ] + 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!(delegates.len_checked(&harness.sdk).unwrap(), 0); - assert_eq!(undelegates.len_checked(&harness.sdk).unwrap(), 0); assert_eq!( - delegation - .pending_undelegated_accessor() + storage + .blocks_in_epoch_accessor() + .entry(1u64) .get_checked(&harness.sdk) .unwrap(), - U256::ZERO + 1 ); + let logs = harness.sdk.take_logs(); + let partial = logs_of(&logs, events::PartialEpoch::SELECTOR); + assert_eq!(partial.len(), 1); assert_eq!( - delegation - .delegate_gap_accessor() + 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!( - delegation - .undelegate_gap_accessor() + 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(10_000); - harness.set_caller(validator); + 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_CLAIM_DELEGATOR_FEE, - &AddressCommand { value: validator }, + 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 ); - assert_eq!(transfers.borrow().len(), 2); + 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/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/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/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 1394230c5..a87f308e4 100644 --- a/crates/codec/tests/roundtrip/mod.rs +++ b/crates/codec/tests/roundtrip/mod.rs @@ -9,5 +9,7 @@ use fluentbase_codec::{ }; mod func; +mod integer_padding; +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)"); + } +} 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" + ); +}