Skip to content

Commit 20420d7

Browse files
committed
SRLabs: introduce staking harness
1 parent fd23620 commit 20420d7

File tree

15 files changed

+882
-63
lines changed

15 files changed

+882
-63
lines changed

.github/workflows/rust.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,20 @@ jobs:
372372
run: |
373373
scripts/runtime-benchmark.sh check
374374
375+
staking-fuzzer-build:
376+
name: staking-fuzzer-build (Linux x86-64)
377+
runs-on: ubuntu-22.04
378+
379+
steps:
380+
- name: Checkout
381+
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
382+
383+
- name: install ziggy
384+
run: cargo install --force ziggy cargo-afl honggfuzz grcov
385+
386+
- name: build fuzzer
387+
run: scripts/build-fuzzer.sh
388+
375389
# This job checks all crates individually, including no_std and other featureless builds.
376390
# We need to check crates individually for missing features, because cargo does feature
377391
# unification, which hides missing features when crates are built together.
@@ -499,6 +513,7 @@ jobs:
499513
- check-runtime-benchmarks
500514
- cargo-check-individually
501515
- cargo-unused-deps
516+
- staking-fuzzer-build
502517
steps:
503518
- name: Check job statuses
504519
# Another hack is to actually check the status of the dependencies or else it'll fall through
@@ -511,3 +526,4 @@ jobs:
511526
[[ "${{ needs.check-runtime-benchmarks.result }}" == "success" ]] || exit 1
512527
[[ "${{ needs.cargo-check-individually.result }}" == "success" ]] || exit 1
513528
[[ "${{ needs.cargo-unused-deps.result }}" == "success" ]] || exit 1
529+
[[ "${{ needs.staking-fuzzer-build.result }}" == "success" ]] || exit 1

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
/.idea
22
/target
3+
./fuzz/staking/target
4+
./fuzz/staking/output

Cargo.lock

Lines changed: 26 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ members = [
1515
"domains/test/service",
1616
"domains/test/utils",
1717
"shared/*",
18+
"fuzz/staking",
1819
"test/subspace-test-client",
1920
"test/subspace-test-runtime",
2021
"test/subspace-test-service",

crates/pallet-domains/Cargo.toml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ sp-version = { workspace = true, features = ["serde"] }
3636
subspace-core-primitives.workspace = true
3737
subspace-runtime-primitives.workspace = true
3838

39+
# fuzz feature optional dependencies
40+
domain-pallet-executive = {workspace = true, optional = true}
41+
pallet-timestamp = {workspace = true, optional = true}
42+
pallet-block-fees = {workspace = true, optional = true}
43+
sp-externalities = {workspace = true, optional = true}
44+
sp-keystore = {workspace = true, optional = true}
45+
3946
[dev-dependencies]
4047
domain-pallet-executive.workspace = true
4148
hex-literal.workspace = true
@@ -85,3 +92,11 @@ runtime-benchmarks = [
8592
"sp-runtime/runtime-benchmarks",
8693
"sp-subspace-mmr/runtime-benchmarks",
8794
]
95+
96+
fuzz = [
97+
"dep:domain-pallet-executive",
98+
"dep:pallet-timestamp",
99+
"dep:pallet-block-fees",
100+
"dep:sp-externalities",
101+
"dep:sp-keystore",
102+
]
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
// Copyright 2025 Security Research Labs GmbH
2+
// Permission to use, copy, modify, and/or distribute this software for
3+
// any purpose with or without fee is hereby granted.
4+
//
5+
// THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL
6+
// WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES
7+
// OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE
8+
// FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY
9+
// DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
10+
// AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
11+
// OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
12+
13+
use alloc::collections::BTreeSet;
14+
use frame_system::Account;
15+
use pallet_balances::{Holds, TotalIssuance};
16+
use sp_core::H256;
17+
use sp_domains::{DomainId, OperatorId};
18+
use sp_runtime::traits::One;
19+
20+
use crate::staking::{
21+
Operator, OperatorStatus, SharePrice, mark_invalid_bundle_author, unmark_invalid_bundle_author,
22+
};
23+
use crate::staking_epoch::do_finalize_domain_current_epoch;
24+
use crate::{
25+
BalanceOf, Config, Deposits, DomainBlockNumberFor, DomainStakingSummary, HeadDomainNumber,
26+
InvalidBundleAuthors, Operators, PendingSlashes, ReceiptHashFor,
27+
};
28+
29+
/// Fetch the next epoch's operators from the DomainStakingSummary
30+
#[allow(clippy::type_complexity)]
31+
pub fn get_next_operators<T: Config>(
32+
domain_id: DomainId,
33+
) -> Vec<Operator<BalanceOf<T>, T::Share, DomainBlockNumberFor<T>, ReceiptHashFor<T>>> {
34+
let domain_summary = DomainStakingSummary::<T>::get(domain_id)
35+
.expect("invariant violated: We must have DomainStakingSummary");
36+
let mut prev_ops = vec![];
37+
for operator_id in &domain_summary.next_operators {
38+
let operator = Operators::<T>::get(*operator_id).expect(
39+
"invariant violated: Operator in next_operator set is not present in Operators",
40+
);
41+
prev_ops.push(operator)
42+
}
43+
prev_ops
44+
}
45+
46+
/// Finalize the epoch and transition to the next one
47+
pub fn conclude_domain_epoch<T: Config>(domain_id: DomainId) {
48+
let head_domain_number = HeadDomainNumber::<T>::get(domain_id);
49+
HeadDomainNumber::<T>::set(domain_id, head_domain_number + One::one());
50+
do_finalize_domain_current_epoch::<T>(domain_id)
51+
.expect("invariant violated: we must be able to finalize domain epoch");
52+
}
53+
54+
/// Mark an operator as having produced an invalid bundle
55+
pub fn fuzz_mark_invalid_bundle_authors<T: Config<DomainHash = H256>>(
56+
operator: OperatorId,
57+
domain_id: DomainId,
58+
) -> Option<H256> {
59+
let pending_slashes = PendingSlashes::<T>::get(domain_id).unwrap_or_default();
60+
let mut invalid_bundle_authors_in_epoch = InvalidBundleAuthors::<T>::get(domain_id);
61+
let mut stake_summary = DomainStakingSummary::<T>::get(domain_id).unwrap();
62+
if pending_slashes.contains(&operator) {
63+
return None;
64+
}
65+
let er = H256::random();
66+
mark_invalid_bundle_author::<T>(
67+
operator,
68+
er,
69+
&mut stake_summary,
70+
&mut invalid_bundle_authors_in_epoch,
71+
)
72+
.expect("invariant violated: could not mark operator as invalid bundle author");
73+
DomainStakingSummary::<T>::insert(domain_id, stake_summary);
74+
InvalidBundleAuthors::<T>::insert(domain_id, invalid_bundle_authors_in_epoch);
75+
Some(er)
76+
}
77+
78+
/// Unmark an operator as having produced an invalid bundle
79+
pub fn fuzz_unmark_invalid_bundle_authors<T: Config<DomainHash = H256>>(
80+
domain_id: DomainId,
81+
operator: OperatorId,
82+
er: H256,
83+
) {
84+
let pending_slashes = PendingSlashes::<T>::get(domain_id).unwrap_or_default();
85+
let mut invalid_bundle_authors_in_epoch = InvalidBundleAuthors::<T>::get(domain_id);
86+
let mut stake_summary = DomainStakingSummary::<T>::get(domain_id).unwrap();
87+
88+
if pending_slashes.contains(&operator)
89+
|| crate::Pallet::<T>::is_operator_pending_to_slash(domain_id, operator)
90+
{
91+
return;
92+
}
93+
94+
unmark_invalid_bundle_author::<T>(
95+
operator,
96+
er,
97+
&mut stake_summary,
98+
&mut invalid_bundle_authors_in_epoch,
99+
)
100+
.expect("invariant violated: could not unmark operator as invalid bundle author");
101+
102+
DomainStakingSummary::<T>::insert(domain_id, stake_summary);
103+
InvalidBundleAuthors::<T>::insert(domain_id, invalid_bundle_authors_in_epoch);
104+
}
105+
106+
/// Fetch operators who are pending slashing
107+
pub fn get_pending_slashes<T: Config>(domain_id: DomainId) -> BTreeSet<OperatorId> {
108+
PendingSlashes::<T>::get(domain_id).unwrap_or_default()
109+
}
110+
111+
/// Check staking invariants before epoch finalization
112+
pub fn check_invariants_before_finalization<T: Config>(domain_id: DomainId) {
113+
let domain_summary = DomainStakingSummary::<T>::get(domain_id).unwrap();
114+
// INVARIANT: all current_operators are registered and not slashed nor have invalid bundles
115+
for operator_id in &domain_summary.next_operators {
116+
let operator = Operators::<T>::get(*operator_id).unwrap();
117+
if !matches!(
118+
operator.status::<T>(*operator_id),
119+
OperatorStatus::Registered
120+
) {
121+
panic!("operator set violated");
122+
}
123+
}
124+
}
125+
126+
/// Check staking invariants after epoch finalization
127+
#[allow(clippy::type_complexity)]
128+
pub fn check_invariants_after_finalization<T: Config<Balance = u128, Share = u128>>(
129+
domain_id: DomainId,
130+
prev_ops: Vec<Operator<BalanceOf<T>, T::Share, DomainBlockNumberFor<T>, ReceiptHashFor<T>>>,
131+
) {
132+
let domain_summary = DomainStakingSummary::<T>::get(domain_id).unwrap();
133+
for operator_id in domain_summary.current_operators.keys() {
134+
let operator = Operators::<T>::get(operator_id).unwrap();
135+
// INVARIANT: 0 < SharePrice < 1
136+
SharePrice::new::<T>(operator.current_total_shares, operator.current_total_stake)
137+
.expect("SharePrice to be present");
138+
}
139+
140+
// INVARIANT: Total domain stake == accumulated operators' curent_stake.
141+
let aggregated_stake: BalanceOf<T> = domain_summary
142+
.current_operators
143+
.values()
144+
.fold(0, |acc, stake| acc.saturating_add(*stake));
145+
146+
assert!(aggregated_stake == domain_summary.current_total_stake);
147+
// INVARIANT: all current_operators are registered and not slashed nor have invalid bundles
148+
for operator_id in domain_summary.current_operators.keys() {
149+
let operator = Operators::<T>::get(operator_id).unwrap();
150+
if !matches!(
151+
operator.status::<T>(*operator_id),
152+
OperatorStatus::Registered
153+
) {
154+
panic!("operator set violated");
155+
}
156+
// INVARIANT: Shares add up
157+
let mut shares: T::Share = 0;
158+
for (operator, _nominator, deposit) in Deposits::<T>::iter() {
159+
if *operator_id == operator {
160+
shares += deposit.known.shares;
161+
}
162+
}
163+
assert!(shares <= operator.current_total_shares);
164+
}
165+
166+
// INVARIANT: all operators which were part of the next operator set before finalization are present now
167+
assert_eq!(prev_ops.len(), domain_summary.current_operators.len());
168+
}
169+
170+
/// Check general Substrate invariants that must always hold
171+
pub fn check_general_invariants<
172+
T: Config<Balance = u128>
173+
+ pallet_balances::Config<Balance = u128>
174+
+ frame_system::Config<AccountData = pallet_balances::AccountData<u128>>,
175+
>(
176+
initial_total_issuance: BalanceOf<T>,
177+
) {
178+
// After execution of all blocks, we run invariants
179+
let mut counted_free: <T as pallet_balances::Config>::Balance = 0;
180+
let mut counted_reserved: <T as pallet_balances::Config>::Balance = 0;
181+
for (account, info) in Account::<T>::iter() {
182+
let consumers = info.consumers;
183+
let providers = info.providers;
184+
assert!(
185+
!(consumers > 0 && providers == 0),
186+
"Invalid account consumers or providers state"
187+
);
188+
counted_free += info.data.free;
189+
counted_reserved += info.data.reserved;
190+
let max_lock: <T as pallet_balances::Config>::Balance =
191+
pallet_balances::Locks::<T>::get(&account)
192+
.iter()
193+
.map(|l| l.amount)
194+
.max()
195+
.unwrap_or_default();
196+
assert_eq!(
197+
max_lock, info.data.frozen,
198+
"Max lock should be equal to frozen balance"
199+
);
200+
let sum_holds: <T as pallet_balances::Config>::Balance =
201+
Holds::<T>::get(&account).iter().map(|l| l.amount).sum();
202+
assert!(
203+
sum_holds <= info.data.reserved,
204+
"Sum of all holds ({sum_holds}) should be less than or equal to reserved balance {}",
205+
info.data.reserved
206+
);
207+
}
208+
let total_issuance = TotalIssuance::<T>::get();
209+
let counted_issuance = counted_free + counted_reserved;
210+
assert_eq!(total_issuance, counted_issuance);
211+
assert!(total_issuance >= initial_total_issuance);
212+
}

crates/pallet-domains/src/lib.rs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,22 @@ mod benchmarking;
99
#[cfg(test)]
1010
mod tests;
1111

12+
#[cfg(all(not(test), feature = "std", feature = "fuzz"))]
13+
pub mod tests;
14+
1215
pub mod block_tree;
1316
pub mod bundle_storage_fund;
1417
pub mod domain_registry;
1518
pub mod extensions;
19+
#[cfg(feature = "fuzz")]
20+
pub mod fuzz_utils;
1621
pub mod migrations;
1722
mod nominator_position;
1823
pub mod runtime_registry;
1924
pub mod staking;
25+
#[cfg(feature = "fuzz")]
26+
pub mod staking_epoch;
27+
#[cfg(not(feature = "fuzz"))]
2028
mod staking_epoch;
2129
pub mod weights;
2230

@@ -508,7 +516,7 @@ mod pallet {
508516

509517
#[pallet::storage]
510518
#[pallet::getter(fn domain_staking_summary)]
511-
pub(super) type DomainStakingSummary<T: Config> =
519+
pub(crate) type DomainStakingSummary<T: Config> =
512520
StorageMap<_, Identity, DomainId, StakingSummary<OperatorId, BalanceOf<T>>, OptionQuery>;
513521

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

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

553561
/// List of all withdrawals for a given operator.
554562
#[pallet::storage]
555-
pub(super) type Withdrawals<T: Config> = StorageDoubleMap<
563+
pub(crate) type Withdrawals<T: Config> = StorageDoubleMap<
556564
_,
557565
Identity,
558566
OperatorId,
@@ -571,7 +579,7 @@ mod pallet {
571579
/// When the epoch for a given domain is complete, operator total stake is moved to treasury and
572580
/// then deleted.
573581
#[pallet::storage]
574-
pub(super) type PendingSlashes<T: Config> =
582+
pub(crate) type PendingSlashes<T: Config> =
575583
StorageMap<_, Identity, DomainId, BTreeSet<OperatorId>, OptionQuery>;
576584

577585
/// The pending staking operation count of the current epoch, it should not larger than
@@ -668,7 +676,7 @@ mod pallet {
668676
// the runtime upgrade tx from the consensus chain and no any user submitted tx from the bundle), use
669677
// `domain_best_number` for the actual best domain block
670678
#[pallet::storage]
671-
pub(super) type HeadDomainNumber<T: Config> =
679+
pub(crate) type HeadDomainNumber<T: Config> =
672680
StorageMap<_, Identity, DomainId, DomainBlockNumberFor<T>, ValueQuery>;
673681

674682
/// A temporary storage to hold any previous epoch details for a given domain

0 commit comments

Comments
 (0)