-
Notifications
You must be signed in to change notification settings - Fork 254
SRLabs: Introduce fuzzing harness for pallet-domains #3693
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
96d7831
23065d3
361620f
8000bd6
4d5d7f5
1b0a25c
8e62bf9
f494fad
a890b10
dd50feb
d0a0dc3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This job should fail if:
That way we will check and analyse any fuzzing results or run failures. |
||
|
||
# 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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,2 +1,4 @@ | ||
/.idea | ||
/target | ||
./fuzz/staking/target | ||
./fuzz/staking/output | ||
Comment on lines
+3
to
+4
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this still required now that the crate has been moved into the workspace? |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -15,6 +15,7 @@ members = [ | |
"domains/test/service", | ||
"domains/test/utils", | ||
"shared/*", | ||
"fuzz/staking", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: alphabetical order |
||
"test/subspace-test-client", | ||
"test/subspace-test-runtime", | ||
"test/subspace-test-service", | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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} | ||
R9295 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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", | ||
] | ||
Comment on lines
+96
to
+102
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the This makes the |
Original file line number | Diff line number | Diff line change | ||
---|---|---|---|---|
@@ -0,0 +1,226 @@ | ||||
// Copyright 2025 Security Research Labs GmbH | ||||
R9295 marked this conversation as resolved.
Show resolved
Hide resolved
|
||||
// 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; | ||||
|
||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, 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<T: Config>( | ||||
teor2345 marked this conversation as resolved.
Show resolved
Hide resolved
|
||||
domain_id: DomainId, | ||||
) -> Vec<Operator<BalanceOf<T>, T::Share, DomainBlockNumberFor<T>, ReceiptHashFor<T>>> { | ||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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"); | ||||
} | ||||
} | ||||
// INVARIANT: No operator is common between DeactivatedOperator and DeregisteredOperator | ||||
let deactivated_operators = DeactivatedOperators::<T>::get(domain_id); | ||||
let deregistered_operators = DeregisteredOperators::<T>::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<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: DeactivatedOperators is empty | ||||
let deactivated_operators = DeactivatedOperators::<T>::get(domain_id); | ||||
assert!(deactivated_operators.is_empty()); | ||||
// INVARIANT: DeregisteredOperators is empty | ||||
let deregistered_operators = DeregisteredOperators::<T>::get(domain_id); | ||||
assert!(deregistered_operators.is_empty()); | ||||
|
||||
// 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); | ||||
} |
Uh oh!
There was an error while loading. Please reload this page.