pallet_domains/fuzz/
staking.rs

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
13use crate::fuzz::fuzz_utils::{
14    check_general_invariants, check_invariants_after_finalization,
15    check_invariants_before_finalization, conclude_domain_epoch, fuzz_mark_invalid_bundle_authors,
16    fuzz_unmark_invalid_bundle_authors, get_next_operators, get_pending_slashes,
17};
18use crate::mock::{
19    AccountId, Balance, BalancesConfig, DOMAIN_ID, DomainsConfig, RuntimeGenesisConfig, Test,
20};
21use crate::staking::{
22    do_deactivate_operator, do_deregister_operator, do_mark_operators_as_slashed,
23    do_nominate_operator, do_reactivate_operator, do_register_operator, do_reward_operators,
24    do_unlock_funds, do_unlock_nominator, do_withdraw_stake,
25};
26use crate::staking_epoch::do_slash_operator;
27use crate::{Config, OperatorConfig, SlashedReason};
28use domain_runtime_primitives::DEFAULT_EVM_CHAIN_ID;
29use parity_scale_codec::Encode;
30use sp_core::storage::Storage;
31use sp_core::{H256, Pair};
32use sp_domains::storage::RawGenesis;
33use sp_domains::{
34    GenesisDomain, OperatorAllowList, OperatorId, OperatorPair, PermissionedActionAllowedBy,
35    RuntimeType,
36};
37use sp_runtime::{BuildStorage, Percent};
38use sp_state_machine::BasicExternalities;
39use std::collections::BTreeMap;
40use subspace_runtime_primitives::AI3;
41
42/// The amount of actions per domain epoch
43const ACTIONS_PER_EPOCH: usize = 5;
44/// The amount of epochs per fuzz-run
45const NUM_EPOCHS: usize = 5;
46/// Minimum amount a nominator must stake
47const MIN_NOMINATOR_STAKE: Balance = 20;
48
49#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
50struct FuzzData {
51    /// NUM_EPOCHS epochs with N epochs skipped
52    pub epochs: [(u8, Epoch); NUM_EPOCHS],
53}
54
55#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
56struct Epoch {
57    /// ACTIONS_PER_EPOCH actions split between N users
58    actions: [(u8, FuzzAction); ACTIONS_PER_EPOCH],
59}
60
61/// The actions the harness performs
62/// Each action roughly maps to each extrinsic in pallet-domains.
63/// Note that all amounts MUST be multiplied by AI3 to be sensible
64#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
65enum FuzzAction {
66    RegisterOperator {
67        amount: u16,
68        tax: u8,
69    },
70    NominateOperator {
71        operator_id: u8,
72        amount: u16,
73    },
74    DeregisterOperator {
75        operator_id: u64,
76    },
77    WithdrawStake {
78        nominator_id: u8,
79        operator_id: u8,
80        shares: u16,
81    },
82    UnlockFunds {
83        operator_id: u8,
84        nominator_id: u8,
85    },
86    UnlockNominator {
87        operator_id: u8,
88        nominator_id: u8,
89    },
90    MarkOperatorsAsSlashed {
91        operator_id: u8,
92        slash_reason: u8, // 0 for InvalidBundle, 1 for BadExecutionReceipt
93    },
94    MarkInvalidBundleAuthors {
95        operator_id: u8,
96    },
97    UnmarkInvalidBundleAuthors {
98        operator_id: u8,
99        er_id: u8,
100    },
101    RewardOperator {
102        operator_id: u8,
103        amount: u16,
104    },
105    DeactivateOperator {
106        operator_id: u8,
107    },
108    ReactivateOperator {
109        operator_id: u8,
110    },
111    SlashOperator,
112}
113
114/// Creates the genesis for the consensus chain; pre-configuring one EVM domain
115/// and minting funds to all test accounts.
116fn create_genesis_storage(accounts: &[AccountId], mint: u128) -> Storage {
117    let raw_genesis_storage = RawGenesis::dummy(vec![1, 2, 3, 4]).encode();
118    let pair = OperatorPair::from_seed(&[*accounts.first().unwrap() as u8; 32]);
119    RuntimeGenesisConfig {
120        balances: BalancesConfig {
121            balances: accounts.iter().cloned().map(|k| (k, mint)).collect(),
122        },
123        domains: DomainsConfig {
124            genesis_domains: vec![GenesisDomain {
125                runtime_name: "evm".to_owned(),
126                runtime_type: RuntimeType::Evm,
127                runtime_version: Default::default(),
128                raw_genesis_storage,
129                owner_account_id: *accounts.first().unwrap(),
130                domain_name: "evm-domain".to_owned(),
131                bundle_slot_probability: (1, 1),
132                operator_allow_list: OperatorAllowList::Anyone,
133                signing_key: pair.public(),
134                minimum_nominator_stake: MIN_NOMINATOR_STAKE * AI3,
135                nomination_tax: Percent::from_percent(5),
136                initial_balances: vec![],
137                domain_runtime_info: (DEFAULT_EVM_CHAIN_ID, Default::default()).into(),
138            }],
139            permissioned_action_allowed_by: Some(PermissionedActionAllowedBy::Anyone),
140        },
141        subspace: Default::default(),
142        system: Default::default(),
143    }
144    .build_storage()
145    .unwrap()
146}
147
148pub fn run_staking_fuzz(data: &[u8]) {
149    let accounts: Vec<AccountId> = (0..5).map(|i| (i as u128)).collect();
150    let mint = (u16::MAX as u128) * 2 * AI3;
151    let genesis = create_genesis_storage(&accounts, mint);
152    let Ok(data) = bincode::deserialize(data) else {
153        return;
154    };
155
156    let mut ext = BasicExternalities::new(genesis);
157    ext.execute_with(|| {
158        fuzz(&data, accounts.clone());
159    });
160}
161
162fn fuzz(data: &FuzzData, accounts: Vec<AccountId>) {
163    let mut operators = BTreeMap::new();
164    let mut nominators = BTreeMap::new();
165    let mut invalid_ers = Vec::new();
166
167    // Get initial issuance from the pre-setup state
168    let initial_issuance = accounts
169        .iter()
170        .map(<Test as Config>::Currency::free_balance)
171        .sum();
172
173    for (skip, epoch) in &data.epochs {
174        for (user, action) in epoch.actions.iter() {
175            let user = accounts.get(*user as usize % accounts.len()).unwrap();
176            match action {
177                FuzzAction::RegisterOperator { amount, tax } => {
178                    let res = register_operator(*user, *amount as u128, *tax);
179                    if let Some(operator) = res {
180                        operators.insert(user, operator);
181                        nominators
182                            .entry(*user)
183                            .and_modify(|list: &mut Vec<u64>| list.push(operator))
184                            .or_insert(vec![operator]);
185
186                        println!(
187                            "Registering {user:?} as Operator {operator:?} with amount {amount:?}\n-->{res:?}"
188                        );
189                    } else {
190                        println!(
191                            "Registering {user:?} as Operator (failed) with amount {amount:?} AI3 \n-->{res:?}"
192                        );
193                    }
194                }
195                FuzzAction::NominateOperator {
196                    operator_id,
197                    amount,
198                } => {
199                    if operators.is_empty() {
200                        println!("skipping NominateOperator");
201                        continue;
202                    }
203                    let amount = (*amount as u128).max(MIN_NOMINATOR_STAKE) * AI3;
204                    let operator = operators
205                        .iter()
206                        .collect::<Vec<_>>()
207                        .get(*operator_id as usize % operators.len())
208                        .unwrap()
209                        .1;
210                    let res = do_nominate_operator::<Test>(*operator, *user, amount);
211                    if res.is_ok() {
212                        nominators
213                            .entry(*user)
214                            .and_modify(|list: &mut Vec<u64>| list.push(*operator))
215                            .or_insert(vec![*operator]);
216                    }
217
218                    println!(
219                        "Nominating as Nominator {user:?} for Operator {operator:?} with amount {amount:?}\n-->{res:?}"
220                    );
221                }
222                FuzzAction::DeregisterOperator { operator_id } => {
223                    if operators.is_empty() {
224                        println!("skipping DeregisterOperator");
225                        continue;
226                    }
227                    let (owner, operator) = *operators
228                        .iter()
229                        .collect::<Vec<_>>()
230                        .get(*operator_id as usize % operators.len())
231                        .unwrap();
232                    let res = do_deregister_operator::<Test>(**owner, *operator);
233
234                    println!("de-registering Operator {operator:?} \n-->{res:?}");
235                }
236                FuzzAction::WithdrawStake {
237                    nominator_id,
238                    operator_id,
239                    shares,
240                } => {
241                    if operators.is_empty() {
242                        println!("skipping WithdrawStake");
243                        continue;
244                    }
245                    let (nominator, operators) = *nominators
246                        .iter()
247                        .collect::<Vec<_>>()
248                        .get(*nominator_id as usize % nominators.len())
249                        .unwrap();
250                    let operator = operators
251                        .get(*operator_id as usize % operators.len())
252                        .unwrap();
253                    let res =
254                        do_withdraw_stake::<Test>(*operator, *nominator, *shares as u128 * AI3);
255
256                    println!(
257                        "Withdrawing stake from Operator {operator:?}  as Nominator {nominator:?} of shares {shares:?}\n-->{res:?}"
258                    );
259                }
260                FuzzAction::UnlockFunds {
261                    operator_id,
262                    nominator_id,
263                } => {
264                    if operators.is_empty() {
265                        println!("skipping UnlockFunds");
266                        continue;
267                    }
268                    let (nominator, operators) = *nominators
269                        .iter()
270                        .collect::<Vec<_>>()
271                        .get(*nominator_id as usize % nominators.len())
272                        .unwrap();
273                    let operator = operators
274                        .get(*operator_id as usize % operators.len())
275                        .unwrap();
276                    let res = do_unlock_funds::<Test>(*operator, *nominator);
277
278                    println!(
279                        "Unlocking funds as Nominator {nominator:?} from Operator {operator:?} \n-->{res:?}"
280                    );
281                }
282                FuzzAction::UnlockNominator {
283                    operator_id,
284                    nominator_id,
285                } => {
286                    if operators.is_empty() {
287                        println!("skipping UnlockNominator");
288                        continue;
289                    }
290                    let (nominator, operators) = *nominators
291                        .iter()
292                        .collect::<Vec<_>>()
293                        .get(*nominator_id as usize % nominators.len())
294                        .unwrap();
295                    let operator = operators
296                        .get(*operator_id as usize % operators.len())
297                        .unwrap();
298                    let res = do_unlock_nominator::<Test>(*operator, *nominator);
299
300                    println!(
301                        "Unlocking funds as Nominator {nominator:?} from Operator {operator:?} \n-->{res:?}"
302                    );
303                }
304                FuzzAction::MarkOperatorsAsSlashed {
305                    operator_id,
306                    slash_reason,
307                } => {
308                    if operators.is_empty() {
309                        println!("skipping MarkOperatorsAsSlashed");
310                        continue;
311                    }
312                    let operator = operators
313                        .iter()
314                        .collect::<Vec<_>>()
315                        .get(*operator_id as usize % operators.len())
316                        .unwrap()
317                        .1;
318                    let slash_reason = match slash_reason % 2 {
319                        0 => SlashedReason::InvalidBundle(0),
320                        _ => SlashedReason::BadExecutionReceipt(H256::from([0u8; 32])),
321                    };
322                    let res = do_mark_operators_as_slashed::<Test>(vec![*operator], slash_reason);
323
324                    println!("Marking {operator:?} as slashed\n-->{res:?}");
325                    do_slash_operator::<Test>(DOMAIN_ID, u32::MAX).unwrap();
326                }
327                FuzzAction::SlashOperator => {
328                    if operators.is_empty() {
329                        println!("skipping SlashOperator");
330                        continue;
331                    }
332                    let res = do_slash_operator::<Test>(DOMAIN_ID, u32::MAX);
333                    assert!(res.is_ok());
334
335                    {
336                        let pending_slashes = get_pending_slashes::<Test>(DOMAIN_ID);
337                        println!("Slashing: {pending_slashes:?} -->{res:?}");
338                    }
339                }
340                FuzzAction::RewardOperator {
341                    operator_id,
342                    amount,
343                } => {
344                    if operators.is_empty() {
345                        println!("skipping RewardOperator");
346                        continue;
347                    }
348                    let operator = operators
349                        .iter()
350                        .collect::<Vec<_>>()
351                        .get(*operator_id as usize % operators.len())
352                        .unwrap()
353                        .1;
354                    let reward_amount = 10u128 * AI3;
355                    let res = do_reward_operators::<Test>(
356                        DOMAIN_ID,
357                        sp_domains::OperatorRewardSource::Dummy,
358                        vec![*operator].into_iter(),
359                        reward_amount,
360                    );
361                    assert!(res.is_ok());
362
363                    println!("Rewarding operator {operator:?} with {amount:?} AI3 \n-->{res:?}");
364                }
365                FuzzAction::MarkInvalidBundleAuthors { operator_id } => {
366                    if operators.is_empty() {
367                        println!("skipping MarkInvalidBundleAuthors");
368                        continue;
369                    }
370                    let operator = operators
371                        .iter()
372                        .collect::<Vec<_>>()
373                        .get(*operator_id as usize % operators.len())
374                        .unwrap()
375                        .1;
376                    if let Some(invalid_er) =
377                        fuzz_mark_invalid_bundle_authors::<Test>(*operator, DOMAIN_ID)
378                    {
379                        invalid_ers.push(invalid_er)
380                    }
381                }
382                FuzzAction::UnmarkInvalidBundleAuthors { operator_id, er_id } => {
383                    if operators.is_empty() {
384                        println!("skipping UnmarkInvalidBundleAuthors");
385                        continue;
386                    }
387                    if invalid_ers.is_empty() {
388                        println!("skipping UnmarkInvalidBundleAuthors");
389                        continue;
390                    }
391                    let operator = operators
392                        .iter()
393                        .collect::<Vec<_>>()
394                        .get(*operator_id as usize % operators.len())
395                        .unwrap()
396                        .1;
397                    let er = invalid_ers
398                        .get(*er_id as usize % invalid_ers.len())
399                        .unwrap();
400                    fuzz_unmark_invalid_bundle_authors::<Test>(DOMAIN_ID, *operator, *er);
401                }
402                FuzzAction::DeactivateOperator { operator_id } => {
403                    if operators.is_empty() {
404                        println!("skipping DeactivateOperator");
405                        continue;
406                    }
407                    let operator = operators
408                        .iter()
409                        .collect::<Vec<_>>()
410                        .get(*operator_id as usize % operators.len())
411                        .unwrap()
412                        .1;
413                    let res = do_deactivate_operator::<Test>(*operator);
414
415                    println!("Deactivating {operator:?} \n-->{res:?}");
416                }
417                FuzzAction::ReactivateOperator { operator_id } => {
418                    if operators.is_empty() {
419                        println!("skipping ReactivateOperator");
420                        continue;
421                    }
422                    let operator = operators
423                        .iter()
424                        .collect::<Vec<_>>()
425                        .get(*operator_id as usize % operators.len())
426                        .unwrap()
427                        .1;
428                    let res = do_reactivate_operator::<Test>(*operator);
429
430                    println!("Deactivating {operator:?} \n-->{res:?}");
431                }
432            }
433            check_invariants_before_finalization::<Test>(DOMAIN_ID);
434            let prev_validator_states = get_next_operators::<Test>(DOMAIN_ID);
435            conclude_domain_epoch::<Test>(DOMAIN_ID);
436            check_invariants_after_finalization::<Test>(DOMAIN_ID, prev_validator_states);
437            check_general_invariants::<Test>(initial_issuance);
438
439            println!("skipping {skip:?} epochs");
440            for _ in 0..*skip {
441                conclude_domain_epoch::<Test>(DOMAIN_ID);
442            }
443        }
444    }
445}
446
447/// Registers an operator for staking with fuzzer provided tax and amount
448fn register_operator(operator: AccountId, amount: Balance, tax: u8) -> Option<OperatorId> {
449    let pair = OperatorPair::from_seed(&[operator as u8; 32]);
450    let config = OperatorConfig {
451        signing_key: pair.public(),
452        minimum_nominator_stake: MIN_NOMINATOR_STAKE * AI3,
453        nomination_tax: sp_runtime::Percent::from_percent(tax.min(100)),
454    };
455    let res = do_register_operator::<Test>(operator, DOMAIN_ID, amount * AI3, config);
456    if let Ok((id, _)) = res {
457        Some(id)
458    } else {
459        None
460    }
461}