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            dev_accounts: None,
123        },
124        domains: DomainsConfig {
125            genesis_domains: vec![GenesisDomain {
126                runtime_name: "evm".to_owned(),
127                runtime_type: RuntimeType::Evm,
128                runtime_version: Default::default(),
129                raw_genesis_storage,
130                owner_account_id: *accounts.first().unwrap(),
131                domain_name: "evm-domain".to_owned(),
132                bundle_slot_probability: (1, 1),
133                operator_allow_list: OperatorAllowList::Anyone,
134                signing_key: pair.public(),
135                minimum_nominator_stake: MIN_NOMINATOR_STAKE * AI3,
136                nomination_tax: Percent::from_percent(5),
137                initial_balances: vec![],
138                domain_runtime_info: (DEFAULT_EVM_CHAIN_ID, Default::default()).into(),
139            }],
140            permissioned_action_allowed_by: Some(PermissionedActionAllowedBy::Anyone),
141        },
142        subspace: Default::default(),
143        system: Default::default(),
144    }
145    .build_storage()
146    .unwrap()
147}
148
149pub fn run_staking_fuzz(data: &[u8]) {
150    let accounts: Vec<AccountId> = (0..5).map(|i| (i as u128)).collect();
151    let mint = (u16::MAX as u128) * 2 * AI3;
152    let genesis = create_genesis_storage(&accounts, mint);
153    let Ok(data) = bincode::deserialize(data) else {
154        return;
155    };
156
157    let mut ext = BasicExternalities::new(genesis);
158    ext.execute_with(|| {
159        fuzz(&data, accounts.clone());
160    });
161}
162
163fn fuzz(data: &FuzzData, accounts: Vec<AccountId>) {
164    let mut operators = BTreeMap::new();
165    let mut nominators = BTreeMap::new();
166    let mut invalid_ers = Vec::new();
167
168    // Get initial issuance from the pre-setup state
169    let initial_issuance = accounts
170        .iter()
171        .map(<Test as Config>::Currency::free_balance)
172        .sum();
173
174    for (skip, epoch) in &data.epochs {
175        for (user, action) in epoch.actions.iter() {
176            let user = accounts.get(*user as usize % accounts.len()).unwrap();
177            match action {
178                FuzzAction::RegisterOperator { amount, tax } => {
179                    let res = register_operator(*user, *amount as u128, *tax);
180                    if let Some(operator) = res {
181                        operators.insert(user, operator);
182                        nominators
183                            .entry(*user)
184                            .and_modify(|list: &mut Vec<u64>| list.push(operator))
185                            .or_insert(vec![operator]);
186
187                        println!(
188                            "Registering {user:?} as Operator {operator:?} with amount {amount:?}\n-->{res:?}"
189                        );
190                    } else {
191                        println!(
192                            "Registering {user:?} as Operator (failed) with amount {amount:?} AI3 \n-->{res:?}"
193                        );
194                    }
195                }
196                FuzzAction::NominateOperator {
197                    operator_id,
198                    amount,
199                } => {
200                    if operators.is_empty() {
201                        println!("skipping NominateOperator");
202                        continue;
203                    }
204                    let amount = (*amount as u128).max(MIN_NOMINATOR_STAKE) * AI3;
205                    let operator = operators
206                        .iter()
207                        .collect::<Vec<_>>()
208                        .get(*operator_id as usize % operators.len())
209                        .unwrap()
210                        .1;
211                    let res = do_nominate_operator::<Test>(*operator, *user, amount);
212                    if res.is_ok() {
213                        nominators
214                            .entry(*user)
215                            .and_modify(|list: &mut Vec<u64>| list.push(*operator))
216                            .or_insert(vec![*operator]);
217                    }
218
219                    println!(
220                        "Nominating as Nominator {user:?} for Operator {operator:?} with amount {amount:?}\n-->{res:?}"
221                    );
222                }
223                FuzzAction::DeregisterOperator { operator_id } => {
224                    if operators.is_empty() {
225                        println!("skipping DeregisterOperator");
226                        continue;
227                    }
228                    let (owner, operator) = *operators
229                        .iter()
230                        .collect::<Vec<_>>()
231                        .get(*operator_id as usize % operators.len())
232                        .unwrap();
233                    let res = do_deregister_operator::<Test>(**owner, *operator);
234
235                    println!("de-registering Operator {operator:?} \n-->{res:?}");
236                }
237                FuzzAction::WithdrawStake {
238                    nominator_id,
239                    operator_id,
240                    shares,
241                } => {
242                    if operators.is_empty() {
243                        println!("skipping WithdrawStake");
244                        continue;
245                    }
246                    let (nominator, operators) = *nominators
247                        .iter()
248                        .collect::<Vec<_>>()
249                        .get(*nominator_id as usize % nominators.len())
250                        .unwrap();
251                    let operator = operators
252                        .get(*operator_id as usize % operators.len())
253                        .unwrap();
254                    let res =
255                        do_withdraw_stake::<Test>(*operator, *nominator, *shares as u128 * AI3);
256
257                    println!(
258                        "Withdrawing stake from Operator {operator:?}  as Nominator {nominator:?} of shares {shares:?}\n-->{res:?}"
259                    );
260                }
261                FuzzAction::UnlockFunds {
262                    operator_id,
263                    nominator_id,
264                } => {
265                    if operators.is_empty() {
266                        println!("skipping UnlockFunds");
267                        continue;
268                    }
269                    let (nominator, operators) = *nominators
270                        .iter()
271                        .collect::<Vec<_>>()
272                        .get(*nominator_id as usize % nominators.len())
273                        .unwrap();
274                    let operator = operators
275                        .get(*operator_id as usize % operators.len())
276                        .unwrap();
277                    let res = do_unlock_funds::<Test>(*operator, *nominator);
278
279                    println!(
280                        "Unlocking funds as Nominator {nominator:?} from Operator {operator:?} \n-->{res:?}"
281                    );
282                }
283                FuzzAction::UnlockNominator {
284                    operator_id,
285                    nominator_id,
286                } => {
287                    if operators.is_empty() {
288                        println!("skipping UnlockNominator");
289                        continue;
290                    }
291                    let (nominator, operators) = *nominators
292                        .iter()
293                        .collect::<Vec<_>>()
294                        .get(*nominator_id as usize % nominators.len())
295                        .unwrap();
296                    let operator = operators
297                        .get(*operator_id as usize % operators.len())
298                        .unwrap();
299                    let res = do_unlock_nominator::<Test>(*operator, *nominator);
300
301                    println!(
302                        "Unlocking funds as Nominator {nominator:?} from Operator {operator:?} \n-->{res:?}"
303                    );
304                }
305                FuzzAction::MarkOperatorsAsSlashed {
306                    operator_id,
307                    slash_reason,
308                } => {
309                    if operators.is_empty() {
310                        println!("skipping MarkOperatorsAsSlashed");
311                        continue;
312                    }
313                    let operator = operators
314                        .iter()
315                        .collect::<Vec<_>>()
316                        .get(*operator_id as usize % operators.len())
317                        .unwrap()
318                        .1;
319                    let slash_reason = match slash_reason % 2 {
320                        0 => SlashedReason::InvalidBundle(0),
321                        _ => SlashedReason::BadExecutionReceipt(H256::from([0u8; 32])),
322                    };
323                    let res = do_mark_operators_as_slashed::<Test>(vec![*operator], slash_reason);
324
325                    println!("Marking {operator:?} as slashed\n-->{res:?}");
326                    do_slash_operator::<Test>(DOMAIN_ID, u32::MAX).unwrap();
327                }
328                FuzzAction::SlashOperator => {
329                    if operators.is_empty() {
330                        println!("skipping SlashOperator");
331                        continue;
332                    }
333                    let res = do_slash_operator::<Test>(DOMAIN_ID, u32::MAX);
334                    assert!(res.is_ok());
335
336                    {
337                        let pending_slashes = get_pending_slashes::<Test>(DOMAIN_ID);
338                        println!("Slashing: {pending_slashes:?} -->{res:?}");
339                    }
340                }
341                FuzzAction::RewardOperator {
342                    operator_id,
343                    amount,
344                } => {
345                    if operators.is_empty() {
346                        println!("skipping RewardOperator");
347                        continue;
348                    }
349                    let operator = operators
350                        .iter()
351                        .collect::<Vec<_>>()
352                        .get(*operator_id as usize % operators.len())
353                        .unwrap()
354                        .1;
355                    let reward_amount = 10u128 * AI3;
356                    let res = do_reward_operators::<Test>(
357                        DOMAIN_ID,
358                        sp_domains::OperatorRewardSource::Dummy,
359                        vec![*operator].into_iter(),
360                        reward_amount,
361                    );
362                    assert!(res.is_ok());
363
364                    println!("Rewarding operator {operator:?} with {amount:?} AI3 \n-->{res:?}");
365                }
366                FuzzAction::MarkInvalidBundleAuthors { operator_id } => {
367                    if operators.is_empty() {
368                        println!("skipping MarkInvalidBundleAuthors");
369                        continue;
370                    }
371                    let operator = operators
372                        .iter()
373                        .collect::<Vec<_>>()
374                        .get(*operator_id as usize % operators.len())
375                        .unwrap()
376                        .1;
377                    if let Some(invalid_er) =
378                        fuzz_mark_invalid_bundle_authors::<Test>(*operator, DOMAIN_ID)
379                    {
380                        invalid_ers.push(invalid_er)
381                    }
382                }
383                FuzzAction::UnmarkInvalidBundleAuthors { operator_id, er_id } => {
384                    if operators.is_empty() {
385                        println!("skipping UnmarkInvalidBundleAuthors");
386                        continue;
387                    }
388                    if invalid_ers.is_empty() {
389                        println!("skipping UnmarkInvalidBundleAuthors");
390                        continue;
391                    }
392                    let operator = operators
393                        .iter()
394                        .collect::<Vec<_>>()
395                        .get(*operator_id as usize % operators.len())
396                        .unwrap()
397                        .1;
398                    let er = invalid_ers
399                        .get(*er_id as usize % invalid_ers.len())
400                        .unwrap();
401                    fuzz_unmark_invalid_bundle_authors::<Test>(DOMAIN_ID, *operator, *er);
402                }
403                FuzzAction::DeactivateOperator { operator_id } => {
404                    if operators.is_empty() {
405                        println!("skipping DeactivateOperator");
406                        continue;
407                    }
408                    let operator = operators
409                        .iter()
410                        .collect::<Vec<_>>()
411                        .get(*operator_id as usize % operators.len())
412                        .unwrap()
413                        .1;
414                    let res = do_deactivate_operator::<Test>(*operator);
415
416                    println!("Deactivating {operator:?} \n-->{res:?}");
417                }
418                FuzzAction::ReactivateOperator { operator_id } => {
419                    if operators.is_empty() {
420                        println!("skipping ReactivateOperator");
421                        continue;
422                    }
423                    let operator = operators
424                        .iter()
425                        .collect::<Vec<_>>()
426                        .get(*operator_id as usize % operators.len())
427                        .unwrap()
428                        .1;
429                    let res = do_reactivate_operator::<Test>(*operator);
430
431                    println!("Deactivating {operator:?} \n-->{res:?}");
432                }
433            }
434            check_invariants_before_finalization::<Test>(DOMAIN_ID);
435            let prev_validator_states = get_next_operators::<Test>(DOMAIN_ID);
436            conclude_domain_epoch::<Test>(DOMAIN_ID);
437            check_invariants_after_finalization::<Test>(DOMAIN_ID, prev_validator_states);
438            check_general_invariants::<Test>(initial_issuance);
439
440            println!("skipping {skip:?} epochs");
441            for _ in 0..*skip {
442                conclude_domain_epoch::<Test>(DOMAIN_ID);
443            }
444        }
445    }
446}
447
448/// Registers an operator for staking with fuzzer provided tax and amount
449fn register_operator(operator: AccountId, amount: Balance, tax: u8) -> Option<OperatorId> {
450    let pair = OperatorPair::from_seed(&[operator as u8; 32]);
451    let config = OperatorConfig {
452        signing_key: pair.public(),
453        minimum_nominator_stake: MIN_NOMINATOR_STAKE * AI3,
454        nomination_tax: sp_runtime::Percent::from_percent(tax.min(100)),
455    };
456    let res = do_register_operator::<Test>(operator, DOMAIN_ID, amount * AI3, config);
457    if let Ok((id, _)) = res {
458        Some(id)
459    } else {
460        None
461    }
462}