Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions crates/pallet-domains/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Copy link
Member

@teor2345 teor2345 Sep 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A new section would make this clearer:

Suggested change
domain-pallet-executive = {workspace = true, optional = 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
Expand Down Expand Up @@ -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",
]
211 changes: 211 additions & 0 deletions crates/pallet-domains/src/fuzz_utils.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
// Copyright 2025 Security Research Labs GmbH
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rest of the crate is 0BSD, do you mind updating this copyright to match?
https://opensource.org/license/0bsd

I also have the same licence ask for the fuzzing crate.

// 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;

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Most of the crate uses a single import block. This might require a rustfmt run after this change.

Suggested change

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, Deposits, DomainBlockNumberFor, DomainStakingSummary, HeadDomainNumber,
InvalidBundleAuthors, Operators, PendingSlashes, ReceiptHashFor, Withdrawals,
};

/// Fetch the next epoch's operators from the DomainStakingSummary
#[allow(clippy::type_complexity)]
pub fn get_next_operators<T: Config>(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add comments explaining what each new function and struct does. One sentence is enough.

domain_id: DomainId,
) -> Vec<Operator<BalanceOf<T>, T::Share, DomainBlockNumberFor<T>, ReceiptHashFor<T>>> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: We usually use a type alias to make complex types more readable. If there isn't one available in the crate, feel free to add one to this file.

let domain_summary = DomainStakingSummary::<T>::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::<T>::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<T: Config>(domain_id: DomainId) {
let head_domain_number = HeadDomainNumber::<T>::get(domain_id);
HeadDomainNumber::<T>::set(domain_id, head_domain_number + One::one());
do_finalize_domain_current_epoch::<T>(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<T: Config<DomainHash = H256>>(
operator: OperatorId,
domain_id: DomainId,
) -> Option<H256> {
let pending_slashes = PendingSlashes::<T>::get(domain_id).unwrap_or_default();
let mut invalid_bundle_authors_in_epoch = InvalidBundleAuthors::<T>::get(domain_id);
let mut stake_summary = DomainStakingSummary::<T>::get(domain_id).unwrap();
if pending_slashes.contains(&operator) {
return None;
}
let er = H256::random();
mark_invalid_bundle_author::<T>(
operator,
er,
&mut stake_summary,
&mut invalid_bundle_authors_in_epoch,
)
.expect("invariant violated: could not mark operator as invalid bundle author");
DomainStakingSummary::<T>::insert(domain_id, stake_summary);
InvalidBundleAuthors::<T>::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<T: Config<DomainHash = H256>>(
domain_id: DomainId,
operator: OperatorId,
er: H256,
) {
let pending_slashes = PendingSlashes::<T>::get(domain_id).unwrap_or_default();
let mut invalid_bundle_authors_in_epoch = InvalidBundleAuthors::<T>::get(domain_id);
let mut stake_summary = DomainStakingSummary::<T>::get(domain_id).unwrap();

if pending_slashes.contains(&operator)
|| crate::Pallet::<T>::is_operator_pending_to_slash(domain_id, operator)
{
return;
}

unmark_invalid_bundle_author::<T>(
operator,
er,
&mut stake_summary,
&mut invalid_bundle_authors_in_epoch,
)
.expect("invariant violated: could not unmark operator as invalid bundle author");

DomainStakingSummary::<T>::insert(domain_id, stake_summary);
InvalidBundleAuthors::<T>::insert(domain_id, invalid_bundle_authors_in_epoch);
}

/// Fetch operators who are pending slashing
pub fn get_pending_slashes<T: Config>(domain_id: DomainId) -> BTreeSet<OperatorId> {
PendingSlashes::<T>::get(domain_id).unwrap_or_default()
}

/// Check staking invariants before epoch finalization
pub fn check_invariants_before_finalization<T: Config>(domain_id: DomainId) {
let domain_summary = DomainStakingSummary::<T>::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::<T>::get(*operator_id).unwrap();
if !matches!(
operator.status::<T>(*operator_id),
OperatorStatus::Registered
) {
panic!("operator set violated");
}
}
}


/// Check staking invariants after epoch finalization
#[allow(clippy::type_complexity)]
pub fn check_invariants_after_finalization<T: Config<Balance = u128, Share = u128>>(
domain_id: DomainId,
prev_ops: Vec<Operator<BalanceOf<T>, T::Share, DomainBlockNumberFor<T>, ReceiptHashFor<T>>>,
) {
let domain_summary = DomainStakingSummary::<T>::get(domain_id).unwrap();
for operator_id in domain_summary.current_operators.keys() {
let operator = Operators::<T>::get(operator_id).unwrap();
// INVARIANT: 0 < SharePrice < 1
SharePrice::new::<T>(operator.current_total_shares, operator.current_total_stake)
.expect("SharePrice to be present");
}

// INVARIANT: Total domain stake == accumulated operators' curent_stake.
let aggregated_stake: BalanceOf<T> = 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::<T>::get(operator_id).unwrap();
if !matches!(
operator.status::<T>(*operator_id),
OperatorStatus::Registered
) {
panic!("operator set violated");
}
// INVARIANT: Shares add up
let mut shares: T::Share = 0;
for (operator, _nominator, deposit) in Deposits::<T>::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<Balance = u128>
+ pallet_balances::Config<Balance = u128>
+ frame_system::Config<AccountData = pallet_balances::AccountData<u128>>,
>(
initial_total_issuance: BalanceOf<T>,
) {
// After execution of all blocks, we run invariants
let mut counted_free: <T as pallet_balances::Config>::Balance = 0;
let mut counted_reserved: <T as pallet_balances::Config>::Balance = 0;
for (account, info) in Account::<T>::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: <T as pallet_balances::Config>::Balance =
pallet_balances::Locks::<T>::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: <T as pallet_balances::Config>::Balance =
Holds::<T>::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::<T>::get();
let counted_issuance = counted_free + counted_reserved;
assert_eq!(total_issuance, counted_issuance);
assert!(total_issuance >= initial_total_issuance);
}
18 changes: 13 additions & 5 deletions crates/pallet-domains/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -508,7 +516,7 @@ mod pallet {

#[pallet::storage]
#[pallet::getter(fn domain_staking_summary)]
pub(super) type DomainStakingSummary<T: Config> =
pub(crate) type DomainStakingSummary<T: Config> =
StorageMap<_, Identity, DomainId, StakingSummary<OperatorId, BalanceOf<T>>, OptionQuery>;

/// List of all registered operators and their configuration.
Expand Down Expand Up @@ -540,7 +548,7 @@ mod pallet {

/// List of all deposits for given Operator.
#[pallet::storage]
pub(super) type Deposits<T: Config> = StorageDoubleMap<
pub(crate) type Deposits<T: Config> = StorageDoubleMap<
_,
Identity,
OperatorId,
Expand All @@ -552,7 +560,7 @@ mod pallet {

/// List of all withdrawals for a given operator.
#[pallet::storage]
pub(super) type Withdrawals<T: Config> = StorageDoubleMap<
pub(crate) type Withdrawals<T: Config> = StorageDoubleMap<
_,
Identity,
OperatorId,
Expand All @@ -571,7 +579,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<T: Config> =
pub(crate) type PendingSlashes<T: Config> =
StorageMap<_, Identity, DomainId, BTreeSet<OperatorId>, OptionQuery>;

/// The pending staking operation count of the current epoch, it should not larger than
Expand Down Expand Up @@ -668,7 +676,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<T: Config> =
pub(crate) type HeadDomainNumber<T: Config> =
StorageMap<_, Identity, DomainId, DomainBlockNumberFor<T>, ValueQuery>;

/// A temporary storage to hold any previous epoch details for a given domain
Expand Down
Loading
Loading