diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index bd252f3f75..2190cbcc33 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -372,6 +372,26 @@ jobs: run: | scripts/runtime-benchmark.sh check + staking-fuzzer-test: + name: staking-fuzzer-test (Linux x86-64) + # Fuzzing is most efficient on Linux, it doesn't matter if it fails on other OSes. + # Our runs-on instances don't have the required packages + runs-on: ubuntu-22.04 + # Don't use the full 6 hours if fuzzing hangs + timeout-minutes: 120 + env: + AFL_SKIP_CPUFREQ: 1 + AFL_I_DONT_CARE_ABOUT_MISSING_CRASHES: 1 + steps: + - name: Checkout + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: install ziggy + run: cargo install --force ziggy cargo-afl honggfuzz grcov + + - name: test fuzzer + run: scripts/run-fuzzer-ci.sh + # This job checks all crates individually, including no_std and other featureless builds. # We need to check crates individually for missing features, because cargo does feature # unification, which hides missing features when crates are built together. @@ -499,6 +519,7 @@ jobs: - check-runtime-benchmarks - cargo-check-individually - cargo-unused-deps + - staking-fuzzer-test steps: - name: Check job statuses # Another hack is to actually check the status of the dependencies or else it'll fall through @@ -511,3 +532,4 @@ jobs: [[ "${{ needs.check-runtime-benchmarks.result }}" == "success" ]] || exit 1 [[ "${{ needs.cargo-check-individually.result }}" == "success" ]] || exit 1 [[ "${{ needs.cargo-unused-deps.result }}" == "success" ]] || exit 1 + [[ "${{ needs.staking-fuzzer-test.result }}" == "success" ]] || exit 1 diff --git a/.gitignore b/.gitignore index f419dc7baa..b813f4fa53 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ /.idea /target +./fuzz/staking/target +./fuzz/staking/output diff --git a/Cargo.lock b/Cargo.lock index ce506d67b3..0b623b1b0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4628,6 +4628,26 @@ dependencies = [ "slab", ] +[[package]] +name = "fuzz-staking" +version = "0.1.0" +dependencies = [ + "bincode", + "domain-runtime-primitives", + "frame-support", + "pallet-balances", + "pallet-domains", + "parity-scale-codec", + "serde", + "sp-core", + "sp-domains", + "sp-io", + "sp-runtime", + "sp-state-machine", + "subspace-runtime-primitives", + "ziggy", +] + [[package]] name = "fxhash" version = "0.2.1" @@ -16511,6 +16531,12 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "ziggy" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1ae470de366d6fd62f31423eb880c06c73b04bceaeedf87864891e9d32d51d9" + [[package]] name = "zstd" version = "0.11.2+zstd.1.5.2" diff --git a/Cargo.toml b/Cargo.toml index 266406936d..697c86a76d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ members = [ "domains/test/service", "domains/test/utils", "shared/*", + "fuzz/staking", "test/subspace-test-client", "test/subspace-test-runtime", "test/subspace-test-service", diff --git a/crates/pallet-domains/Cargo.toml b/crates/pallet-domains/Cargo.toml index c850bef6c6..467f6a6e51 100644 --- a/crates/pallet-domains/Cargo.toml +++ b/crates/pallet-domains/Cargo.toml @@ -36,6 +36,13 @@ sp-version = { workspace = true, features = ["serde"] } subspace-core-primitives.workspace = true subspace-runtime-primitives.workspace = true +# fuzz feature optional dependencies +domain-pallet-executive = {workspace = true, optional = true} +pallet-timestamp = {workspace = true, optional = true} +pallet-block-fees = {workspace = true, optional = true} +sp-externalities = {workspace = true, optional = true} +sp-keystore = {workspace = true, optional = true} + [dev-dependencies] domain-pallet-executive.workspace = true hex-literal.workspace = true @@ -85,3 +92,11 @@ runtime-benchmarks = [ "sp-runtime/runtime-benchmarks", "sp-subspace-mmr/runtime-benchmarks", ] + +fuzz = [ + "dep:domain-pallet-executive", + "dep:pallet-timestamp", + "dep:pallet-block-fees", + "dep:sp-externalities", + "dep:sp-keystore", +] diff --git a/crates/pallet-domains/src/fuzz_utils.rs b/crates/pallet-domains/src/fuzz_utils.rs new file mode 100644 index 0000000000..a17bc26276 --- /dev/null +++ b/crates/pallet-domains/src/fuzz_utils.rs @@ -0,0 +1,226 @@ +// Copyright 2025 Security Research Labs GmbH +// Permission to use, copy, modify, and/or distribute this software for +// any purpose with or without fee is hereby granted. +// +// THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL +// WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES +// OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE +// FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY +// DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +// AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT +// OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +use alloc::collections::BTreeSet; +use frame_system::Account; +use pallet_balances::{Holds, TotalIssuance}; +use sp_core::H256; +use sp_domains::{DomainId, OperatorId}; +use sp_runtime::traits::One; + +use crate::staking::{ + Operator, OperatorStatus, SharePrice, mark_invalid_bundle_author, unmark_invalid_bundle_author, +}; +use crate::staking_epoch::do_finalize_domain_current_epoch; +use crate::{ + BalanceOf, Config, DeactivatedOperators, Deposits, DeregisteredOperators, DomainBlockNumberFor, + DomainStakingSummary, HeadDomainNumber, InvalidBundleAuthors, Operators, PendingSlashes, + ReceiptHashFor, +}; + +/// Fetch the next epoch's operators from the DomainStakingSummary +#[allow(clippy::type_complexity)] +pub fn get_next_operators( + domain_id: DomainId, +) -> Vec, T::Share, DomainBlockNumberFor, ReceiptHashFor>> { + let domain_summary = DomainStakingSummary::::get(domain_id) + .expect("invariant violated: We must have DomainStakingSummary"); + let mut prev_ops = vec![]; + for operator_id in &domain_summary.next_operators { + let operator = Operators::::get(*operator_id).expect( + "invariant violated: Operator in next_operator set is not present in Operators", + ); + prev_ops.push(operator) + } + prev_ops +} + +/// Finalize the epoch and transition to the next one +pub fn conclude_domain_epoch(domain_id: DomainId) { + let head_domain_number = HeadDomainNumber::::get(domain_id); + HeadDomainNumber::::set(domain_id, head_domain_number + One::one()); + do_finalize_domain_current_epoch::(domain_id) + .expect("invariant violated: we must be able to finalize domain epoch"); +} + +/// Mark an operator as having produced an invalid bundle +pub fn fuzz_mark_invalid_bundle_authors>( + operator: OperatorId, + domain_id: DomainId, +) -> Option { + let pending_slashes = PendingSlashes::::get(domain_id).unwrap_or_default(); + let mut invalid_bundle_authors_in_epoch = InvalidBundleAuthors::::get(domain_id); + let mut stake_summary = DomainStakingSummary::::get(domain_id).unwrap(); + if pending_slashes.contains(&operator) { + return None; + } + let er = H256::random(); + mark_invalid_bundle_author::( + operator, + er, + &mut stake_summary, + &mut invalid_bundle_authors_in_epoch, + ) + .expect("invariant violated: could not mark operator as invalid bundle author"); + DomainStakingSummary::::insert(domain_id, stake_summary); + InvalidBundleAuthors::::insert(domain_id, invalid_bundle_authors_in_epoch); + Some(er) +} + +/// Unmark an operator as having produced an invalid bundle +pub fn fuzz_unmark_invalid_bundle_authors>( + domain_id: DomainId, + operator: OperatorId, + er: H256, +) { + let pending_slashes = PendingSlashes::::get(domain_id).unwrap_or_default(); + let mut invalid_bundle_authors_in_epoch = InvalidBundleAuthors::::get(domain_id); + let mut stake_summary = DomainStakingSummary::::get(domain_id).unwrap(); + + if pending_slashes.contains(&operator) + || crate::Pallet::::is_operator_pending_to_slash(domain_id, operator) + { + return; + } + + unmark_invalid_bundle_author::( + operator, + er, + &mut stake_summary, + &mut invalid_bundle_authors_in_epoch, + ) + .expect("invariant violated: could not unmark operator as invalid bundle author"); + + DomainStakingSummary::::insert(domain_id, stake_summary); + InvalidBundleAuthors::::insert(domain_id, invalid_bundle_authors_in_epoch); +} + +/// Fetch operators who are pending slashing +pub fn get_pending_slashes(domain_id: DomainId) -> BTreeSet { + PendingSlashes::::get(domain_id).unwrap_or_default() +} + +/// Check staking invariants before epoch finalization +pub fn check_invariants_before_finalization(domain_id: DomainId) { + let domain_summary = DomainStakingSummary::::get(domain_id).unwrap(); + // INVARIANT: all current_operators are registered and not slashed nor have invalid bundles + for operator_id in &domain_summary.next_operators { + let operator = Operators::::get(*operator_id).unwrap(); + if !matches!( + operator.status::(*operator_id), + OperatorStatus::Registered + ) { + panic!("operator set violated"); + } + } + // INVARIANT: No operator is common between DeactivatedOperator and DeregisteredOperator + let deactivated_operators = DeactivatedOperators::::get(domain_id); + let deregistered_operators = DeregisteredOperators::::get(domain_id); + for operator_id in &deregistered_operators { + assert!(!deactivated_operators.contains(operator_id)); + } +} + +/// Check staking invariants after epoch finalization +#[allow(clippy::type_complexity)] +pub fn check_invariants_after_finalization>( + domain_id: DomainId, + prev_ops: Vec, T::Share, DomainBlockNumberFor, ReceiptHashFor>>, +) { + let domain_summary = DomainStakingSummary::::get(domain_id).unwrap(); + for operator_id in domain_summary.current_operators.keys() { + let operator = Operators::::get(operator_id).unwrap(); + // INVARIANT: 0 < SharePrice < 1 + SharePrice::new::(operator.current_total_shares, operator.current_total_stake) + .expect("SharePrice to be present"); + } + + // INVARIANT: DeactivatedOperators is empty + let deactivated_operators = DeactivatedOperators::::get(domain_id); + assert!(deactivated_operators.is_empty()); + // INVARIANT: DeregisteredOperators is empty + let deregistered_operators = DeregisteredOperators::::get(domain_id); + assert!(deregistered_operators.is_empty()); + + // INVARIANT: Total domain stake == accumulated operators' curent_stake. + let aggregated_stake: BalanceOf = domain_summary + .current_operators + .values() + .fold(0, |acc, stake| acc.saturating_add(*stake)); + + assert!(aggregated_stake == domain_summary.current_total_stake); + // INVARIANT: all current_operators are registered and not slashed nor have invalid bundles + for operator_id in domain_summary.current_operators.keys() { + let operator = Operators::::get(operator_id).unwrap(); + if !matches!( + operator.status::(*operator_id), + OperatorStatus::Registered + ) { + panic!("operator set violated"); + } + // INVARIANT: Shares add up + let mut shares: T::Share = 0; + for (operator, _nominator, deposit) in Deposits::::iter() { + if *operator_id == operator { + shares += deposit.known.shares; + } + } + assert!(shares <= operator.current_total_shares); + } + + // INVARIANT: all operators which were part of the next operator set before finalization are present now + assert_eq!(prev_ops.len(), domain_summary.current_operators.len()); +} + +/// Check general Substrate invariants that must always hold +pub fn check_general_invariants< + T: Config + + pallet_balances::Config + + frame_system::Config>, +>( + initial_total_issuance: BalanceOf, +) { + // After execution of all blocks, we run invariants + let mut counted_free: ::Balance = 0; + let mut counted_reserved: ::Balance = 0; + for (account, info) in Account::::iter() { + let consumers = info.consumers; + let providers = info.providers; + assert!( + !(consumers > 0 && providers == 0), + "Invalid account consumers or providers state" + ); + counted_free += info.data.free; + counted_reserved += info.data.reserved; + let max_lock: ::Balance = + pallet_balances::Locks::::get(&account) + .iter() + .map(|l| l.amount) + .max() + .unwrap_or_default(); + assert_eq!( + max_lock, info.data.frozen, + "Max lock should be equal to frozen balance" + ); + let sum_holds: ::Balance = + Holds::::get(&account).iter().map(|l| l.amount).sum(); + assert!( + sum_holds <= info.data.reserved, + "Sum of all holds ({sum_holds}) should be less than or equal to reserved balance {}", + info.data.reserved + ); + } + let total_issuance = TotalIssuance::::get(); + let counted_issuance = counted_free + counted_reserved; + assert_eq!(total_issuance, counted_issuance); + assert!(total_issuance >= initial_total_issuance); +} diff --git a/crates/pallet-domains/src/lib.rs b/crates/pallet-domains/src/lib.rs index 1f2c2ad480..fc2297a6f8 100644 --- a/crates/pallet-domains/src/lib.rs +++ b/crates/pallet-domains/src/lib.rs @@ -9,14 +9,22 @@ mod benchmarking; #[cfg(test)] mod tests; +#[cfg(all(not(test), feature = "std", feature = "fuzz"))] +pub mod tests; + pub mod block_tree; pub mod bundle_storage_fund; pub mod domain_registry; pub mod extensions; +#[cfg(feature = "fuzz")] +pub mod fuzz_utils; pub mod migrations; mod nominator_position; pub mod runtime_registry; pub mod staking; +#[cfg(feature = "fuzz")] +pub mod staking_epoch; +#[cfg(not(feature = "fuzz"))] mod staking_epoch; pub mod weights; @@ -540,7 +548,7 @@ mod pallet { #[pallet::storage] #[pallet::getter(fn domain_staking_summary)] - pub(super) type DomainStakingSummary = + pub(crate) type DomainStakingSummary = StorageMap<_, Identity, DomainId, StakingSummary>, OptionQuery>; /// List of all registered operators and their configuration. @@ -572,7 +580,7 @@ mod pallet { /// List of all deposits for given Operator. #[pallet::storage] - pub(super) type Deposits = StorageDoubleMap< + pub(crate) type Deposits = StorageDoubleMap< _, Identity, OperatorId, @@ -584,7 +592,7 @@ mod pallet { /// List of all withdrawals for a given operator. #[pallet::storage] - pub(super) type Withdrawals = StorageDoubleMap< + pub(crate) type Withdrawals = StorageDoubleMap< _, Identity, OperatorId, @@ -603,7 +611,7 @@ mod pallet { /// When the epoch for a given domain is complete, operator total stake is moved to treasury and /// then deleted. #[pallet::storage] - pub(super) type PendingSlashes = + pub(crate) type PendingSlashes = StorageMap<_, Identity, DomainId, BTreeSet, OptionQuery>; /// The pending staking operation count of the current epoch, it should not larger than @@ -700,7 +708,7 @@ mod pallet { // the runtime upgrade tx from the consensus chain and no any user submitted tx from the bundle), use // `domain_best_number` for the actual best domain block #[pallet::storage] - pub(super) type HeadDomainNumber = + pub(crate) type HeadDomainNumber = StorageMap<_, Identity, DomainId, DomainBlockNumberFor, ValueQuery>; /// A temporary storage to hold any previous epoch details for a given domain diff --git a/crates/pallet-domains/src/staking.rs b/crates/pallet-domains/src/staking.rs index 64e042b122..21993b3795 100644 --- a/crates/pallet-domains/src/staking.rs +++ b/crates/pallet-domains/src/staking.rs @@ -584,7 +584,7 @@ pub(crate) fn do_convert_previous_epoch_withdrawal( Ok(()) } -pub(crate) fn do_nominate_operator( +pub fn do_nominate_operator( operator_id: OperatorId, nominator_id: T::AccountId, amount: BalanceOf, @@ -674,7 +674,7 @@ pub(crate) fn hold_deposit( /// Deregisters a given operator who is either registered or deactivated. /// Operator is removed from the next operator set. -pub(crate) fn do_deregister_operator( +pub fn do_deregister_operator( operator_owner: T::AccountId, operator_id: OperatorId, ) -> Result { @@ -739,7 +739,7 @@ pub(crate) fn do_deregister_operator( /// Operator status is marked as Deactivated with epoch_index after which they can reactivate back /// into operator set. Their stake is removed from the total domain stake since they will not be /// producing bundles anymore until re-registration. -pub(crate) fn do_deactivate_operator(operator_id: OperatorId) -> Result<(), Error> { +pub fn do_deactivate_operator(operator_id: OperatorId) -> Result<(), Error> { Operators::::try_mutate(operator_id, |maybe_operator| { let operator = maybe_operator.as_mut().ok_or(Error::UnknownOperator)?; @@ -794,7 +794,7 @@ pub(crate) fn do_deactivate_operator(operator_id: OperatorId) -> Resu /// Reactivate a given deactivated operator if the activation delay in epochs has passed. /// The operator is added to next operator set and will be able to produce bundles from next epoch. -pub(crate) fn do_reactivate_operator(operator_id: OperatorId) -> Result<(), Error> { +pub fn do_reactivate_operator(operator_id: OperatorId) -> Result<(), Error> { Operators::::try_mutate(operator_id, |maybe_operator| { let operator = maybe_operator.as_mut().ok_or(Error::UnknownOperator)?; let operator_status = operator.status::(operator_id); @@ -871,7 +871,7 @@ pub(crate) fn current_share_price( /// Absolute stake amount and percentage withdrawals can be handled in the frontend. /// Full stake withdrawals are handled by withdrawing everything, if the remaining number of shares /// is less than the minimum nominator stake, and the nominator is not the operator. -pub(crate) fn do_withdraw_stake( +pub fn do_withdraw_stake( operator_id: OperatorId, nominator_id: NominatorId, to_withdraw: T::Share, @@ -1082,7 +1082,7 @@ pub(crate) fn do_withdraw_stake( /// Unlocks any withdraws that are ready to be unlocked. /// /// Return the number of withdrawals being unlocked -pub(crate) fn do_unlock_funds( +pub fn do_unlock_funds( operator_id: OperatorId, nominator_id: NominatorId, ) -> Result { @@ -1239,9 +1239,8 @@ pub(crate) fn do_unlock_funds( Ok(withdrawal_count) }) } - /// Unlocks an already de-registered operator's nominator given unlock wait period is complete. -pub(crate) fn do_unlock_nominator( +pub fn do_unlock_nominator( operator_id: OperatorId, nominator_id: NominatorId, ) -> Result<(), Error> { @@ -1448,7 +1447,7 @@ pub(crate) fn do_cleanup_operator( } /// Distribute the reward to the operators equally and drop any dust to treasury. -pub(crate) fn do_reward_operators( +pub fn do_reward_operators( domain_id: DomainId, source: OperatorRewardSource>, operators: IntoIter, @@ -1506,10 +1505,9 @@ pub(crate) fn do_reward_operators( ) }) } - /// Freezes the slashed operators and moves the operator to be removed once the domain they are /// operating finishes the epoch. -pub(crate) fn do_mark_operators_as_slashed( +pub fn do_mark_operators_as_slashed( operator_ids: impl AsRef<[OperatorId]>, slash_reason: SlashedReason, ReceiptHashFor>, ) -> Result<(), Error> { @@ -1566,9 +1564,9 @@ pub(crate) fn do_mark_operators_as_slashed( Ok(()) } - /// Mark all the invalid bundle authors from this ER and remove them from operator set. -pub(crate) fn do_mark_invalid_bundle_authors( +/// NOTE: any changes to this must be reflected in the fuzz_utils' equivalent +pub fn do_mark_invalid_bundle_authors( domain_id: DomainId, er: &ExecutionReceiptOf, ) -> Result<(), Error> { @@ -1596,8 +1594,7 @@ pub(crate) fn do_mark_invalid_bundle_authors( InvalidBundleAuthors::::insert(domain_id, invalid_bundle_authors_in_epoch); Ok(()) } - -pub(crate) fn mark_invalid_bundle_author( +pub fn mark_invalid_bundle_author( operator_id: OperatorId, er_hash: ReceiptHashFor, stake_summary: &mut StakingSummary>, @@ -1635,11 +1632,11 @@ pub(crate) fn mark_invalid_bundle_author( Ok(()) }) } - /// Unmark all the invalid bundle authors from this ER that were marked invalid. /// Assumed the ER is invalid and add the marked operators as registered and add them /// back to next operator set. -pub(crate) fn do_unmark_invalid_bundle_authors( +/// NOTE: any changes to this must be reflected in the fuzz_utils' equivalent +pub fn do_unmark_invalid_bundle_authors( domain_id: DomainId, er: &ExecutionReceiptOf, ) -> Result<(), Error> { @@ -1669,8 +1666,7 @@ pub(crate) fn do_unmark_invalid_bundle_authors( InvalidBundleAuthors::::insert(domain_id, invalid_bundle_authors_in_epoch); Ok(()) } - -fn unmark_invalid_bundle_author( +pub fn unmark_invalid_bundle_author( operator_id: OperatorId, er_hash: ReceiptHashFor, stake_summary: &mut StakingSummary>, diff --git a/crates/pallet-domains/src/staking_epoch.rs b/crates/pallet-domains/src/staking_epoch.rs index 1f9eb1fbd5..a64eddeb4d 100644 --- a/crates/pallet-domains/src/staking_epoch.rs +++ b/crates/pallet-domains/src/staking_epoch.rs @@ -33,7 +33,7 @@ pub enum Error { OperatorRewardStaking(TransitionError), } -pub(crate) struct EpochTransitionResult { +pub struct EpochTransitionResult { pub rewarded_operator_count: u32, pub finalized_operator_count: u32, pub completed_epoch_index: EpochIndex, @@ -41,7 +41,7 @@ pub(crate) struct EpochTransitionResult { /// Finalizes the domain's current epoch and begins the next epoch. /// Returns true of the epoch indeed was finished and the number of operator processed. -pub(crate) fn do_finalize_domain_current_epoch( +pub fn do_finalize_domain_current_epoch( domain_id: DomainId, ) -> Result { // Reset pending staking operation count to 0 @@ -431,7 +431,7 @@ pub(crate) fn mint_into_treasury(amount: BalanceOf) -> Result<(), /// Slashes any pending slashed operators. /// At max slashes the `max_nominator_count` under given operator -pub(crate) fn do_slash_operator( +pub fn do_slash_operator( domain_id: DomainId, max_nominator_count: u32, ) -> Result { diff --git a/crates/pallet-domains/src/tests.rs b/crates/pallet-domains/src/tests.rs index c77a431ea3..9b4d4a1937 100644 --- a/crates/pallet-domains/src/tests.rs +++ b/crates/pallet-domains/src/tests.rs @@ -1,75 +1,120 @@ -use crate::block_tree::{BlockTreeNode, verify_execution_receipt}; -use crate::domain_registry::{DomainConfig, DomainConfigParams, DomainObject}; -use crate::runtime_registry::ScheduledRuntimeUpgrade; -use crate::staking_epoch::do_finalize_domain_current_epoch; +#[cfg(test)] +use crate::block_tree::verify_execution_receipt; +#[cfg(test)] +use crate::domain_registry::{DomainConfig, DomainObject}; + +#[cfg(test)] +use crate::Config; +#[cfg(test)] use crate::tests::pallet_mock_version_store::MockPreviousBundleAndExecutionReceiptVersions; +use crate::{self as pallet_domains, BlockSlot, FungibleHoldId}; +#[cfg(test)] use crate::{ - self as pallet_domains, BalanceOf, BlockSlot, BlockTree, BlockTreeNodes, BundleError, Config, - ConsensusBlockHash, DomainBlockNumberFor, DomainHashingFor, DomainRegistry, - DomainRuntimeUpgradeRecords, DomainRuntimeUpgrades, ExecutionInbox, ExecutionReceiptOf, - FraudProofError, FungibleHoldId, HeadDomainNumber, HeadReceiptNumber, NextDomainId, - OperatorConfig, RawOrigin as DomainOrigin, RuntimeRegistry, ScheduledRuntimeUpgrades, + BalanceOf, BlockTree, BlockTreeNodes, BundleError, ConsensusBlockHash, DomainBlockNumberFor, + DomainHashingFor, DomainRegistry, DomainRuntimeUpgradeRecords, DomainRuntimeUpgrades, + ExecutionInbox, ExecutionReceiptOf, FraudProofError, HeadDomainNumber, HeadReceiptNumber, + NextDomainId, OperatorConfig, OperatorId, ProofOfElection, RawOrigin as DomainOrigin, + RuntimeId, RuntimeRegistry, ScheduledRuntimeUpgrades, block_tree::BlockTreeNode, + domain_registry::DomainConfigParams, runtime_registry::ScheduledRuntimeUpgrade, + staking_epoch::do_finalize_domain_current_epoch, }; use core::mem; +use domain_runtime_primitives::BlockNumber as DomainBlockNumber; +#[cfg(test)] +use domain_runtime_primitives::DEFAULT_EVM_CHAIN_ID; use domain_runtime_primitives::opaque::Header as DomainHeader; -use domain_runtime_primitives::{BlockNumber as DomainBlockNumber, DEFAULT_EVM_CHAIN_ID}; -use frame_support::dispatch::{DispatchInfo, RawOrigin}; -use frame_support::traits::{ConstU64, Currency, Hooks, VariantCount}; +#[cfg(test)] +use frame_support::assert_err; +use frame_support::dispatch::DispatchInfo; +#[cfg(test)] +use frame_support::dispatch::RawOrigin; +use frame_support::traits::{ConstU64, VariantCount}; use frame_support::weights::constants::ParityDbWeight; use frame_support::weights::{IdentityFee, Weight}; -use frame_support::{PalletId, assert_err, assert_ok, derive_impl, parameter_types}; +use frame_support::{PalletId, derive_impl, parameter_types}; +#[cfg(test)] +use frame_support::{ + assert_ok, + traits::{Currency, Hooks}, +}; use frame_system::mocking::MockUncheckedExtrinsic; use frame_system::pallet_prelude::*; -use hex_literal::hex; use pallet_subspace::NormalEraChange; use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; use scale_info::TypeInfo; +#[cfg(test)] use sp_consensus_slots::Slot; +#[cfg(test)] use sp_core::crypto::Pair; use sp_core::{Get, H256}; +use sp_domains::bundle::BundleVersion; +#[cfg(test)] use sp_domains::bundle::bundle_v0::{BundleHeaderV0, BundleV0, SealedBundleHeaderV0}; -use sp_domains::bundle::{BundleVersion, InboxedBundle, OpaqueBundle}; -use sp_domains::bundle_producer_election::make_transcript; +use sp_domains::execution_receipt::ExecutionReceiptVersion; +#[cfg(test)] use sp_domains::execution_receipt::execution_receipt_v0::ExecutionReceiptV0; -use sp_domains::execution_receipt::{ExecutionReceipt, ExecutionReceiptVersion, SingletonReceipt}; -use sp_domains::merkle_tree::MerkleTree; -use sp_domains::storage::RawGenesis; -use sp_domains::{ - BundleAndExecutionReceiptVersion, ChainId, DomainId, EMPTY_EXTRINSIC_ROOT, EpochIndex, - OperatorAllowList, OperatorId, OperatorPair, OperatorSignature, ProofOfElection, RuntimeId, - RuntimeType, -}; +use sp_domains::{BundleAndExecutionReceiptVersion, ChainId, DomainId}; +#[cfg(test)] +use sp_domains::{EMPTY_EXTRINSIC_ROOT, OperatorSignature}; +#[cfg(test)] +use sp_domains::{OperatorAllowList, bundle::OpaqueBundle}; +#[cfg(test)] +use sp_domains::{OperatorPair, bundle::InboxedBundle, merkle_tree::MerkleTree}; +#[cfg(test)] +use sp_domains::{RuntimeType, execution_receipt::ExecutionReceipt, storage::RawGenesis}; +#[cfg(test)] +use sp_domains::{bundle_producer_election::make_transcript, execution_receipt::SingletonReceipt}; +#[cfg(test)] use sp_domains_fraud_proof::fraud_proof::FraudProof; +#[cfg(test)] use sp_keystore::Keystore; +#[cfg(test)] use sp_keystore::testing::MemoryKeystore; +use sp_runtime::BuildStorage; +#[cfg(test)] +use sp_runtime::OpaqueExtrinsic; +#[cfg(test)] use sp_runtime::app_crypto::AppCrypto; -use sp_runtime::generic::{EXTRINSIC_FORMAT_VERSION, Preamble}; -use sp_runtime::traits::{ - AccountIdConversion, BlakeTwo256, BlockNumberProvider, Bounded, ConstU16, Hash as HashT, - IdentityLookup, One, Zero, -}; +#[cfg(test)] +use sp_runtime::generic::EXTRINSIC_FORMAT_VERSION; +#[cfg(test)] +use sp_runtime::generic::Preamble; +#[cfg(test)] +use sp_runtime::traits::BlakeTwo256; +#[cfg(test)] +use sp_runtime::traits::Zero; +use sp_runtime::traits::{AccountIdConversion, ConstU16, IdentityLookup}; +#[cfg(test)] +use sp_runtime::traits::{BlockNumberProvider, Bounded, Hash as HashT, One}; use sp_runtime::transaction_validity::TransactionValidityError; +#[cfg(test)] use sp_runtime::type_with_default::TypeWithDefault; -use sp_runtime::{BuildStorage, OpaqueExtrinsic}; use sp_version::{ApiId, RuntimeVersion, create_apis_vec}; use std::num::NonZeroU64; +use subspace_core_primitives::SlotNumber; +#[cfg(test)] +use subspace_core_primitives::U256 as P256; use subspace_core_primitives::pieces::Piece; +#[cfg(test)] use subspace_core_primitives::pot::PotOutput; use subspace_core_primitives::segments::HistorySize; use subspace_core_primitives::solutions::SolutionRange; -use subspace_core_primitives::{SlotNumber, U256 as P256}; use subspace_runtime_primitives::{ - AI3, BlockHashFor, ConsensusEventSegmentSize, HoldIdentifier, Moment, Nonce, StorageFee, + AI3, ConsensusEventSegmentSize, HoldIdentifier, Moment, StorageFee, }; +#[cfg(test)] +use subspace_runtime_primitives::{BlockHashFor, Nonce}; +#[cfg(test)] type UncheckedExtrinsic = frame_system::mocking::MockUncheckedExtrinsic; type Block = frame_system::mocking::MockBlockU32; -type Balance = u128; +pub type Balance = u128; // TODO: Remove when DomainRegistry is usable. -const DOMAIN_ID: DomainId = DomainId::new(0); +pub const DOMAIN_ID: DomainId = DomainId::new(0); // Operator id used for testing +#[cfg(test)] const OPERATOR_ID: OperatorId = 0u64; // Core Api version ID and default APIs @@ -97,7 +142,7 @@ frame_support::construct_runtime!( type BlockNumber = u32; type Hash = H256; -pub(crate) type AccountId = u128; +pub type AccountId = u128; #[derive_impl(frame_system::config_preludes::TestDefaultConfig)] impl frame_system::Config for Test { @@ -184,7 +229,7 @@ parameter_types! { bundle_version: BundleVersion::V0, execution_receipt_version: ExecutionReceiptVersion::V0, }; - pub const OperatorActivationDelayInEpochs: EpochIndex = 5; + pub const OperatorActivationDelayInEpochs: sp_domains::EpochIndex = 5; } pub struct MockRandomness; @@ -442,7 +487,7 @@ pub(crate) mod pallet_mock_version_store { impl pallet_mock_version_store::Config for Test {} -pub(crate) fn new_test_ext() -> sp_io::TestExternalities { +pub fn new_test_ext() -> sp_io::TestExternalities { let t = frame_system::GenesisConfig::::default() .build_storage() .unwrap(); @@ -450,7 +495,7 @@ pub(crate) fn new_test_ext() -> sp_io::TestExternalities { t.into() } -pub(crate) fn new_test_ext_with_extensions() -> sp_io::TestExternalities { +pub fn new_test_ext_with_extensions() -> sp_io::TestExternalities { let version = RuntimeVersion { spec_name: "test".into(), impl_name: Default::default(), @@ -469,6 +514,7 @@ pub(crate) fn new_test_ext_with_extensions() -> sp_io::TestExternalities { ext } +#[cfg(test)] pub(crate) fn create_dummy_receipt( block_number: BlockNumber, consensus_block_hash: Hash, @@ -509,6 +555,7 @@ pub(crate) fn create_dummy_receipt( }) } +#[cfg(test)] fn create_dummy_bundle( domain_id: DomainId, block_number: BlockNumber, @@ -528,6 +575,7 @@ fn create_dummy_bundle( ) } +#[cfg(test)] pub(crate) fn create_dummy_bundle_with_receipts( domain_id: DomainId, operator_id: OperatorId, @@ -563,6 +611,7 @@ impl sp_core::traits::ReadRuntimeVersion for ReadRuntimeVersion { } } +#[cfg(test)] pub(crate) fn run_to_block(block_number: BlockNumberFor, parent_hash: T::Hash) { // Finalize the previous block // on_finalize() does not run on the genesis block @@ -580,6 +629,7 @@ pub(crate) fn run_to_block(block_number: BlockNumberFor, parent_ha } } +#[cfg(test)] pub(crate) fn register_genesis_domain(creator: u128, operator_number: usize) -> DomainId { let raw_genesis_storage = RawGenesis::dummy(vec![1, 2, 3, 4]).encode(); assert_ok!(crate::Pallet::::set_permissioned_action_allowed_by( @@ -634,6 +684,7 @@ pub(crate) fn register_genesis_domain(creator: u128, operator_number: usize) -> } // Submit new head receipt to extend the block tree from the genesis block +#[cfg(test)] pub(crate) fn extend_block_tree_from_zero( domain_id: DomainId, operator_id: u64, @@ -646,6 +697,7 @@ pub(crate) fn extend_block_tree_from_zero( } // Submit new head receipt to extend the block tree +#[cfg(test)] pub(crate) fn extend_block_tree( domain_id: DomainId, operator_id: u64, @@ -693,6 +745,7 @@ pub(crate) fn extend_block_tree( } #[allow(clippy::type_complexity)] +#[cfg(test)] pub(crate) fn get_block_tree_node_at( domain_id: DomainId, block_number: DomainBlockNumberFor, @@ -1009,6 +1062,7 @@ fn test_basic_fraud_proof_processing() { } } +#[cfg(test)] fn schedule_domain_runtime_upgrade( runtime_id: RuntimeId, scheduled_at: BlockNumberFor, @@ -1207,6 +1261,7 @@ fn test_type_with_default_nonce_encode() { /// Code is upgraded from block_number + 1 and any new version from new runtime is considered /// from that point which is block_number + 1 /// until block_number, previous runtime's version is valid. +#[cfg(test)] fn get_mock_upgrades() -> Vec<(u32, MockBundleAndExecutionReceiptVersion, bool)> { vec![ // version from 0..100 @@ -1323,6 +1378,7 @@ fn get_mock_upgrades() -> Vec<(u32, MockBundleAndExecutionReceiptVersion, bool)> /// (block_number, current_version) /// block_number: Consensus block at which ER is derived /// current_version: Version defined at the consensus block number. +#[cfg(test)] fn get_mock_version_queries() -> Vec<(u32, MockBundleAndExecutionReceiptVersion)> { vec![ // version from 0..100 @@ -1524,7 +1580,8 @@ fn generate_fixtures_for_benchmarking() { }; let mock_genesis_er_hash = H256::from_slice( - hex!("5207cc85cfd1f53e11f4b9e85bf2d0a4f33e24d0f0f18b818b935a6aa47d3930").as_slice(), + hex_literal::hex!("5207cc85cfd1f53e11f4b9e85bf2d0a4f33e24d0f0f18b818b935a6aa47d3930") + .as_slice(), ); let trace: Vec<::DomainHash> = vec![ diff --git a/fuzz/staking/Cargo.toml b/fuzz/staking/Cargo.toml new file mode 100644 index 0000000000..f531c50dfd --- /dev/null +++ b/fuzz/staking/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "fuzz-staking" +version = "0.1.0" +edition.workspace = true +authors = ["Aarnav Bos "] +license = "0BSD" +homepage = "https://subspace.network" +repository = "https://github.com/autonomys/subspace" +description = "Fuzzing harness for Subspace's staking pallet" + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] + +[dependencies] +serde = { version = "1.0", features = ["derive"]} +bincode = {version = "1.0" } +pallet-domains = {path = "../../crates/pallet-domains", features = ["std", "fuzz"]} +ziggy = { version = "1.3.2", default-features = false } +sp-domains.workspace = true +parity-scale-codec = { workspace = true, features = ["derive"] } +subspace-runtime-primitives = {workspace = true } +domain-runtime-primitives.workspace = true +frame-support.workspace = true +sp-runtime.workspace = true +sp-core.workspace = true +pallet-balances.workspace = true +sp-state-machine.workspace = true +sp-io.workspace = true + +[features] +fuzzing = [] + diff --git a/fuzz/staking/README.md b/fuzz/staking/README.md new file mode 100644 index 0000000000..0c8e7d082b --- /dev/null +++ b/fuzz/staking/README.md @@ -0,0 +1,20 @@ +## Fuzzing Harness for pallet-domains + +This harness aims to encompass and encode actions performed by operators in pallet-domains to thoroughly test the staking implementation in Autonomys. + +## Orchestrating the campaign +For optimal results, use a grammar fuzzer such as [autarkie](https://github.com/R9295/autarkie) to consistently generate valid inputs. + +If you cannot use Autarkie, then it is recommended to use [ziggy](https://github.com/srlabs/ziggy/). Ziggy uses [AFL++](https://github.com/AFLplusplus/AFLplusplus/) and [honggfuzz](https://github.com/google/honggfuzz) under the hood. +Please refer to its documentation for details. + +Command to install ziggy: +``` +cargo install --force ziggy cargo-afl honggfuzz grcov +``` + +Quickstart command to fuzz: +``` bash +cargo ziggy fuzz -j$(nproc) -t1 +``` + diff --git a/fuzz/staking/src/main.rs b/fuzz/staking/src/main.rs new file mode 100644 index 0000000000..a0900fcbc6 --- /dev/null +++ b/fuzz/staking/src/main.rs @@ -0,0 +1,475 @@ +// Copyright 2025 Security Research Labs GmbH +// Permission to use, copy, modify, and/or distribute this software for +// any purpose with or without fee is hereby granted. +// +// THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL +// WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES +// OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE +// FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY +// DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +// AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT +// OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +use domain_runtime_primitives::DEFAULT_EVM_CHAIN_ID; +use pallet_domains::fuzz_utils::{ + check_general_invariants, check_invariants_after_finalization, + check_invariants_before_finalization, conclude_domain_epoch, fuzz_mark_invalid_bundle_authors, + fuzz_unmark_invalid_bundle_authors, get_next_operators, get_pending_slashes, +}; +use pallet_domains::staking::{ + do_deactivate_operator, do_deregister_operator, do_mark_operators_as_slashed, + do_nominate_operator, do_reactivate_operator, do_register_operator, do_reward_operators, + do_unlock_funds, do_unlock_nominator, do_withdraw_stake, +}; +use pallet_domains::staking_epoch::do_slash_operator; +use pallet_domains::tests::{AccountId, Balance, BalancesConfig, DOMAIN_ID, Test}; +use pallet_domains::{Config, OperatorConfig, SlashedReason}; +use parity_scale_codec::Encode; +use sp_core::storage::Storage; +use sp_core::{H256, Pair}; +use sp_domains::storage::RawGenesis; +use sp_domains::{ + GenesisDomain, OperatorAllowList, OperatorId, OperatorPair, PermissionedActionAllowedBy, + RuntimeType, +}; +use sp_runtime::{BuildStorage, Percent}; +use sp_state_machine::BasicExternalities; +use std::collections::BTreeMap; +use subspace_runtime_primitives::AI3; + +/// The amount of actions per domain epoch +const ACTIONS_PER_EPOCH: usize = 5; +/// The amount of epochs per fuzz-run +const NUM_EPOCHS: usize = 5; +/// Minimum amount a nominator must stake +const MIN_NOMINATOR_STAKE: Balance = 20; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct FuzzData { + /// NUM_EPOCHS epochs with N epochs skipped + pub epochs: [(u8, Epoch); NUM_EPOCHS], +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct Epoch { + /// ACTIONS_PER_EPOCH actions split between N users + actions: [(u8, FuzzAction); ACTIONS_PER_EPOCH], +} + +/// The actions the harness performs +/// Each action roughly maps to each extrinsic in pallet-domains. +/// Note that all amounts MUST be multiplied by AI3 to be sensible +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +enum FuzzAction { + RegisterOperator { + amount: u16, + tax: u8, + }, + NominateOperator { + operator_id: u8, + amount: u16, + }, + DeregisterOperator { + operator_id: u64, + }, + WithdrawStake { + nominator_id: u8, + operator_id: u8, + shares: u16, + }, + UnlockFunds { + operator_id: u8, + nominator_id: u8, + }, + UnlockNominator { + operator_id: u8, + nominator_id: u8, + }, + MarkOperatorsAsSlashed { + operator_id: u8, + slash_reason: u8, // 0 for InvalidBundle, 1 for BadExecutionReceipt + }, + MarkInvalidBundleAuthors { + operator_id: u8, + }, + UnmarkInvalidBundleAuthors { + operator_id: u8, + er_id: u8, + }, + RewardOperator { + operator_id: u8, + amount: u16, + }, + DeactivateOperator { + operator_id: u8, + }, + ReactivateOperator { + operator_id: u8, + }, + SlashOperator, +} + +/// Creates the genesis for the consensus chain; pre-configuring one EVM domain +/// and minting funds to all test accounts. +fn create_genesis_storage(accounts: &[AccountId], mint: u128) -> Storage { + let raw_genesis_storage = RawGenesis::dummy(vec![1, 2, 3, 4]).encode(); + let pair = OperatorPair::from_seed(&[*accounts.first().unwrap() as u8; 32]); + pallet_domains::tests::RuntimeGenesisConfig { + balances: BalancesConfig { + balances: accounts.iter().cloned().map(|k| (k, mint)).collect(), + }, + domains: pallet_domains::tests::DomainsConfig { + genesis_domains: vec![GenesisDomain { + runtime_name: "evm".to_owned(), + runtime_type: RuntimeType::Evm, + runtime_version: Default::default(), + raw_genesis_storage, + owner_account_id: *accounts.first().unwrap(), + domain_name: "evm-domain".to_owned(), + bundle_slot_probability: (1, 1), + operator_allow_list: OperatorAllowList::Anyone, + signing_key: pair.public(), + minimum_nominator_stake: MIN_NOMINATOR_STAKE * AI3, + nomination_tax: Percent::from_percent(5), + initial_balances: vec![], + domain_runtime_info: (DEFAULT_EVM_CHAIN_ID, Default::default()).into(), + }], + permissioned_action_allowed_by: Some(PermissionedActionAllowedBy::Anyone), + }, + subspace: Default::default(), + system: Default::default(), + } + .build_storage() + .unwrap() +} + +fn main() { + let accounts: Vec = (0..5).map(|i| (i as u128)).collect(); + let mint = (u16::MAX as u128) * 2 * AI3; + let genesis = create_genesis_storage(&accounts, mint); + ziggy::fuzz!(|data: &[u8]| { + let Ok(data) = bincode::deserialize(data) else { + return; + }; + // Clone the genesis storage for this fuzz iteration + let mut ext = BasicExternalities::new(genesis.clone()); + ext.execute_with(|| { + fuzz(&data, accounts.clone()); + }); + }); +} + +fn fuzz(data: &FuzzData, accounts: Vec) { + let mut operators = BTreeMap::new(); + let mut nominators = BTreeMap::new(); + let mut invalid_ers = Vec::new(); + + // Get initial issuance from the pre-setup state + let initial_issuance = accounts + .iter() + .map(::Currency::free_balance) + .sum(); + + for (skip, epoch) in &data.epochs { + for (user, action) in epoch.actions.iter() { + let user = accounts.get(*user as usize % accounts.len()).unwrap(); + match action { + FuzzAction::RegisterOperator { amount, tax } => { + let res = register_operator(*user, *amount as u128, *tax); + if let Some(operator) = res { + operators.insert(user, operator); + nominators + .entry(*user) + .and_modify(|list: &mut Vec| list.push(operator)) + .or_insert(vec![operator]); + #[cfg(not(feature = "fuzzing"))] + println!( + "Registering {user:?} as Operator {operator:?} with amount {amount:?}\n-->{res:?}" + ); + } else { + #[cfg(not(feature = "fuzzing"))] + println!( + "Registering {user:?} as Operator (failed) with amount {amount:?} AI3 \n-->{res:?}" + ); + } + } + FuzzAction::NominateOperator { + operator_id, + amount, + } => { + if operators.is_empty() { + #[cfg(not(feature = "fuzzing"))] + println!("skipping NominateOperator"); + continue; + } + let amount = (*amount as u128).max(MIN_NOMINATOR_STAKE) * AI3; + let operator = operators + .iter() + .collect::>() + .get(*operator_id as usize % operators.len()) + .unwrap() + .1; + let res = do_nominate_operator::(*operator, *user, amount); + if res.is_ok() { + nominators + .entry(*user) + .and_modify(|list: &mut Vec| list.push(*operator)) + .or_insert(vec![*operator]); + } + #[cfg(not(feature = "fuzzing"))] + println!( + "Nominating as Nominator {user:?} for Operator {operator:?} with amount {amount:?}\n-->{res:?}" + ); + } + FuzzAction::DeregisterOperator { operator_id } => { + if operators.is_empty() { + #[cfg(not(feature = "fuzzing"))] + println!("skipping DeregisterOperator"); + continue; + } + let (owner, operator) = *operators + .iter() + .collect::>() + .get(*operator_id as usize % operators.len()) + .unwrap(); + let res = do_deregister_operator::(**owner, *operator); + #[cfg(not(feature = "fuzzing"))] + println!("de-registering Operator {operator:?} \n-->{res:?}"); + } + FuzzAction::WithdrawStake { + nominator_id, + operator_id, + shares, + } => { + if operators.is_empty() { + #[cfg(not(feature = "fuzzing"))] + println!("skipping WithdrawStake"); + continue; + } + let (nominator, operators) = *nominators + .iter() + .collect::>() + .get(*nominator_id as usize % nominators.len()) + .unwrap(); + let operator = operators + .get(*operator_id as usize % operators.len()) + .unwrap(); + let res = + do_withdraw_stake::(*operator, *nominator, *shares as u128 * AI3); + #[cfg(not(feature = "fuzzing"))] + println!( + "Withdrawing stake from Operator {operator:?} as Nominator {nominator:?} of shares {shares:?}\n-->{res:?}" + ); + } + FuzzAction::UnlockFunds { + operator_id, + nominator_id, + } => { + if operators.is_empty() { + #[cfg(not(feature = "fuzzing"))] + println!("skipping UnlockFunds"); + continue; + } + let (nominator, operators) = *nominators + .iter() + .collect::>() + .get(*nominator_id as usize % nominators.len()) + .unwrap(); + let operator = operators + .get(*operator_id as usize % operators.len()) + .unwrap(); + let res = do_unlock_funds::(*operator, *nominator); + #[cfg(not(feature = "fuzzing"))] + println!( + "Unlocking funds as Nominator {nominator:?} from Operator {operator:?} \n-->{res:?}" + ); + } + FuzzAction::UnlockNominator { + operator_id, + nominator_id, + } => { + if operators.is_empty() { + #[cfg(not(feature = "fuzzing"))] + println!("skipping UnlockNominator"); + continue; + } + let (nominator, operators) = *nominators + .iter() + .collect::>() + .get(*nominator_id as usize % nominators.len()) + .unwrap(); + let operator = operators + .get(*operator_id as usize % operators.len()) + .unwrap(); + let res = do_unlock_nominator::(*operator, *nominator); + #[cfg(not(feature = "fuzzing"))] + println!( + "Unlocking funds as Nominator {nominator:?} from Operator {operator:?} \n-->{res:?}" + ); + } + FuzzAction::MarkOperatorsAsSlashed { + operator_id, + slash_reason, + } => { + if operators.is_empty() { + #[cfg(not(feature = "fuzzing"))] + println!("skipping MarkOperatorsAsSlashed"); + continue; + } + let operator = operators + .iter() + .collect::>() + .get(*operator_id as usize % operators.len()) + .unwrap() + .1; + let slash_reason = match slash_reason % 2 { + 0 => SlashedReason::InvalidBundle(0), + _ => SlashedReason::BadExecutionReceipt(H256::from([0u8; 32])), + }; + let res = do_mark_operators_as_slashed::(vec![*operator], slash_reason); + #[cfg(not(feature = "fuzzing"))] + println!("Marking {operator:?} as slashed\n-->{res:?}"); + do_slash_operator::(DOMAIN_ID, u32::MAX).unwrap(); + } + FuzzAction::SlashOperator => { + if operators.is_empty() { + #[cfg(not(feature = "fuzzing"))] + println!("skipping SlashOperator"); + continue; + } + let res = do_slash_operator::(DOMAIN_ID, u32::MAX); + assert!(res.is_ok()); + #[cfg(not(feature = "fuzzing"))] + { + let pending_slashes = get_pending_slashes::(DOMAIN_ID); + println!("Slashing: {pending_slashes:?} -->{res:?}"); + } + } + FuzzAction::RewardOperator { + operator_id, + amount, + } => { + if operators.is_empty() { + #[cfg(not(feature = "fuzzing"))] + println!("skipping RewardOperator"); + continue; + } + let operator = operators + .iter() + .collect::>() + .get(*operator_id as usize % operators.len()) + .unwrap() + .1; + let reward_amount = 10u128 * AI3; + let res = do_reward_operators::( + DOMAIN_ID, + sp_domains::OperatorRewardSource::Dummy, + vec![*operator].into_iter(), + reward_amount, + ); + assert!(res.is_ok()); + #[cfg(not(feature = "fuzzing"))] + println!("Rewarding operator {operator:?} with {amount:?} AI3 \n-->{res:?}"); + } + FuzzAction::MarkInvalidBundleAuthors { operator_id } => { + if operators.is_empty() { + #[cfg(not(feature = "fuzzing"))] + println!("skipping MarkInvalidBundleAuthors"); + continue; + } + let operator = operators + .iter() + .collect::>() + .get(*operator_id as usize % operators.len()) + .unwrap() + .1; + if let Some(invalid_er) = + fuzz_mark_invalid_bundle_authors::(*operator, DOMAIN_ID) + { + invalid_ers.push(invalid_er) + } + } + FuzzAction::UnmarkInvalidBundleAuthors { operator_id, er_id } => { + if operators.is_empty() { + #[cfg(not(feature = "fuzzing"))] + println!("skipping UnmarkInvalidBundleAuthors"); + continue; + } + if invalid_ers.is_empty() { + #[cfg(not(feature = "fuzzing"))] + println!("skipping UnmarkInvalidBundleAuthors"); + continue; + } + let operator = operators + .iter() + .collect::>() + .get(*operator_id as usize % operators.len()) + .unwrap() + .1; + let er = invalid_ers + .get(*er_id as usize % invalid_ers.len()) + .unwrap(); + fuzz_unmark_invalid_bundle_authors::(DOMAIN_ID, *operator, *er); + } + FuzzAction::DeactivateOperator { operator_id } => { + if operators.is_empty() { + #[cfg(not(feature = "fuzzing"))] + println!("skipping DeactivateOperator"); + continue; + } + let operator = operators + .iter() + .collect::>() + .get(*operator_id as usize % operators.len()) + .unwrap() + .1; + let res = do_deactivate_operator::(*operator); + #[cfg(not(feature = "fuzzing"))] + println!("Deactivating {operator:?} \n-->{res:?}"); + } + FuzzAction::ReactivateOperator { operator_id } => { + if operators.is_empty() { + #[cfg(not(feature = "fuzzing"))] + println!("skipping ReactivateOperator"); + continue; + } + let operator = operators + .iter() + .collect::>() + .get(*operator_id as usize % operators.len()) + .unwrap() + .1; + let res = do_reactivate_operator::(*operator); + #[cfg(not(feature = "fuzzing"))] + println!("Deactivating {operator:?} \n-->{res:?}"); + } + } + check_invariants_before_finalization::(DOMAIN_ID); + let prev_validator_states = get_next_operators::(DOMAIN_ID); + conclude_domain_epoch::(DOMAIN_ID); + check_invariants_after_finalization::(DOMAIN_ID, prev_validator_states); + check_general_invariants::(initial_issuance); + #[cfg(not(feature = "fuzzing"))] + println!("skipping {skip:?} epochs"); + for _ in 0..*skip { + conclude_domain_epoch::(DOMAIN_ID); + } + } + } +} + +/// Registers an operator for staking with fuzzer provided tax and amount +fn register_operator(operator: AccountId, amount: Balance, tax: u8) -> Option { + let pair = OperatorPair::from_seed(&[operator as u8; 32]); + let config = OperatorConfig { + signing_key: pair.public(), + minimum_nominator_stake: MIN_NOMINATOR_STAKE * AI3, + nomination_tax: sp_runtime::Percent::from_percent(tax.min(100)), + }; + let res = do_register_operator::(operator, DOMAIN_ID, amount * AI3, config); + if let Ok((id, _)) = res { + Some(id) + } else { + None + } +} diff --git a/scripts/find-unused-deps.sh b/scripts/find-unused-deps.sh index 7caddc2644..d43af6d13a 100755 --- a/scripts/find-unused-deps.sh +++ b/scripts/find-unused-deps.sh @@ -20,7 +20,7 @@ fi # `--all-features --exclude-feature rocm` # # -BASE_FEATURES="async-trait,binary,cluster,default-library,domain-block-builder,domain-block-preprocessor,frame-benchmarking-cli,frame-system-benchmarking,hex-literal,kzg,numa,pallet-subspace,pallet-timestamp,pallet-utility,parallel,parking_lot,rand,runtime-benchmarks,sc-client-api,sc-executor,schnorrkel,serde,sp-blockchain,sp-core,sp-io,sp-state-machine,sp-std,sp-storage,static_assertions,std,subspace-proof-of-space-gpu,substrate-wasm-builder,testing,wasm-builder,with-tracing,x509-parser" +BASE_FEATURES="async-trait,binary,cluster,default-library,domain-block-builder,domain-block-preprocessor,frame-benchmarking-cli,frame-system-benchmarking,hex-literal,kzg,numa,pallet-subspace,pallet-timestamp,pallet-utility,parallel,parking_lot,rand,runtime-benchmarks,sc-client-api,sc-executor,schnorrkel,serde,sp-blockchain,sp-core,sp-io,sp-state-machine,sp-std,sp-storage,static_assertions,std,subspace-proof-of-space-gpu,substrate-wasm-builder,testing,wasm-builder,with-tracing,x509-parser,fuzz,fuzzing" if [[ "$(uname)" == "Darwin" ]]; then echo "Skipping GPU features because we're on macOS" EXTRA_FEATURES=("") diff --git a/scripts/run-fuzzer-ci.sh b/scripts/run-fuzzer-ci.sh new file mode 100755 index 0000000000..aa39fa203d --- /dev/null +++ b/scripts/run-fuzzer-ci.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +# http://redsymbol.net/articles/unofficial-bash-strict-mode/ +set -euo pipefail + +sudo apt install -y protobuf-compiler binutils-dev + +cd ./fuzz/staking && cargo ziggy build --no-honggfuzz +# cargo ziggy fuzz doesn't allow us to set a number of runs or a run time limit +timeout --preserve-status 5m cargo ziggy fuzz --release