subspace_malicious_operator/
malicious_bundle_tamper.rs

1use domain_client_operator::{ExecutionReceiptFor, OpaqueBundleFor};
2use parity_scale_codec::{Decode, Encode};
3use sc_client_api::HeaderBackend;
4use sp_api::ProvideRuntimeApi;
5use sp_domain_digests::AsPredigest;
6use sp_domains::core_api::DomainCoreApi;
7use sp_domains::merkle_tree::MerkleTree;
8use sp_domains::{
9    BlockFees, BundleValidity, ChainId, HeaderHashingFor, InvalidBundleType, OperatorPublicKey,
10    OperatorSignature,
11};
12use sp_keystore::KeystorePtr;
13use sp_runtime::traits::{Block as BlockT, Hash as HashT, Header as HeaderT, NumberFor, One, Zero};
14use sp_runtime::{DigestItem, OpaqueExtrinsic, RuntimeAppPublic};
15use sp_weights::Weight;
16use std::collections::{BTreeMap, HashMap};
17use std::error::Error;
18use std::sync::Arc;
19
20const MAX_BAD_RECEIPT_CACHE: u32 = 128;
21
22// TODO: remove dead_code once the `InboxedBundle` variant is used
23// currently blocked due to https://github.com/autonomys/subspace/issues/2287
24#[allow(dead_code)]
25#[derive(Debug)]
26enum BadReceiptType {
27    InboxedBundle,
28    ExtrinsicsRoot,
29    ExecutionTrace,
30    BlockFees,
31    Transfers,
32    DomainBlockHash,
33    ParentReceipt,
34}
35
36struct Random;
37
38impl Random {
39    fn seed() -> u32 {
40        rand::random::<u32>()
41    }
42
43    // Return `true` based on the given probability
44    fn probability(p: f64) -> bool {
45        assert!(p <= 1f64);
46        Self::seed() < ((u32::MAX as f64) * p) as u32
47    }
48}
49
50#[allow(clippy::type_complexity)]
51pub struct MaliciousBundleTamper<Block, CBlock, Client>
52where
53    Block: BlockT,
54    CBlock: BlockT,
55{
56    domain_client: Arc<Client>,
57    keystore: KeystorePtr,
58    // A cache for recently produced bad receipts
59    bad_receipts_cache:
60        BTreeMap<NumberFor<Block>, HashMap<CBlock::Hash, ExecutionReceiptFor<Block, CBlock>>>,
61}
62
63impl<Block, CBlock, Client> MaliciousBundleTamper<Block, CBlock, Client>
64where
65    Block: BlockT,
66    CBlock: BlockT,
67    CBlock::Hash: Decode,
68    Client: HeaderBackend<Block> + ProvideRuntimeApi<Block> + 'static,
69    Client::Api: DomainCoreApi<Block>,
70{
71    pub fn new(domain_client: Arc<Client>, keystore: KeystorePtr) -> Self {
72        MaliciousBundleTamper {
73            domain_client,
74            keystore,
75            bad_receipts_cache: BTreeMap::new(),
76        }
77    }
78
79    pub fn maybe_tamper_bundle(
80        &mut self,
81        opaque_bundle: &mut OpaqueBundleFor<Block, CBlock>,
82        operator_signing_key: &OperatorPublicKey,
83    ) -> Result<(), Box<dyn Error>> {
84        if Random::probability(0.2) {
85            self.make_receipt_fraudulent(&mut opaque_bundle.sealed_header.header.receipt)?;
86            self.reseal_bundle(opaque_bundle, operator_signing_key)?;
87        }
88        if Random::probability(0.1) {
89            self.make_bundle_invalid(opaque_bundle)?;
90            self.reseal_bundle(opaque_bundle, operator_signing_key)?;
91        }
92        Ok(())
93    }
94
95    fn make_receipt_fraudulent(
96        &mut self,
97        receipt: &mut ExecutionReceiptFor<Block, CBlock>,
98    ) -> Result<(), Box<dyn Error>> {
99        // We can't make the genesis receipt into a bad ER
100        if receipt.domain_block_number.is_zero() {
101            return Ok(());
102        }
103        // If a bad receipt is already made for the same domain block, reuse it
104        if let Some(bad_receipts_at) = self.bad_receipts_cache.get(&receipt.domain_block_number) {
105            if let Some(previous_bad_receipt) = bad_receipts_at.get(&receipt.consensus_block_hash) {
106                *receipt = previous_bad_receipt.clone();
107                return Ok(());
108            }
109        }
110
111        let random_seed = Random::seed();
112        let bad_receipt_type = match random_seed % 7 {
113            0 => BadReceiptType::InboxedBundle,
114            1 => BadReceiptType::ExtrinsicsRoot,
115            2 => BadReceiptType::ExecutionTrace,
116            3 => BadReceiptType::BlockFees,
117            4 => BadReceiptType::Transfers,
118            5 => BadReceiptType::DomainBlockHash,
119            6 => BadReceiptType::ParentReceipt,
120            _ => return Ok(()),
121        };
122
123        tracing::info!(
124            ?bad_receipt_type,
125            "Generate bad ER of domain block {}#{}",
126            receipt.domain_block_number,
127            receipt.domain_block_hash,
128        );
129
130        match bad_receipt_type {
131            BadReceiptType::BlockFees => {
132                receipt.block_fees = BlockFees::new(
133                    random_seed.into(),
134                    random_seed.into(),
135                    random_seed.into(),
136                    BTreeMap::default(),
137                );
138            }
139            BadReceiptType::Transfers => {
140                receipt.transfers.transfers_in =
141                    BTreeMap::from_iter([(ChainId::consensus_chain_id(), random_seed.into())]);
142                receipt.transfers.transfers_out =
143                    BTreeMap::from_iter([(0.into(), random_seed.into())]);
144                receipt.transfers.rejected_transfers_claimed =
145                    BTreeMap::from_iter([(random_seed.into(), random_seed.into())]);
146                receipt.transfers.transfers_rejected =
147                    BTreeMap::from_iter([(1.into(), random_seed.into())]);
148            }
149            BadReceiptType::ExecutionTrace => {
150                let mismatch_index = random_seed as usize % receipt.execution_trace.len();
151                match random_seed as usize % 3 {
152                    0 => receipt.execution_trace.push(Default::default()),
153                    1 => {
154                        receipt.execution_trace = receipt
155                            .execution_trace
156                            .clone()
157                            .drain(..)
158                            .take(mismatch_index + 1)
159                            .collect();
160                    }
161                    2 => receipt.execution_trace[mismatch_index] = Default::default(),
162                    _ => unreachable!(),
163                };
164                receipt.final_state_root = *receipt.execution_trace.last().unwrap();
165                receipt.execution_trace_root = {
166                    let trace: Vec<_> = receipt
167                        .execution_trace
168                        .iter()
169                        .map(|t| t.encode().try_into().unwrap())
170                        .collect();
171                    MerkleTree::from_leaves(trace.as_slice())
172                        .root()
173                        .unwrap()
174                        .into()
175                };
176            }
177            BadReceiptType::ExtrinsicsRoot => {
178                receipt.domain_block_extrinsic_root = Default::default();
179            }
180            BadReceiptType::DomainBlockHash => {
181                receipt.domain_block_hash = Default::default();
182            }
183            BadReceiptType::ParentReceipt => {
184                let parent_domain_number = receipt.domain_block_number - One::one();
185                let parent_block_consensus_hash: CBlock::Hash = {
186                    let parent_domain_hash = *self
187                        .domain_client
188                        .header(receipt.domain_block_hash)?
189                        .ok_or_else(|| {
190                            sp_blockchain::Error::Backend(format!(
191                                "Domain block header for #{:?} not found",
192                                receipt.domain_block_hash
193                            ))
194                        })?
195                        .parent_hash();
196                    let parent_domain_header = self
197                        .domain_client
198                        .header(parent_domain_hash)?
199                        .ok_or_else(|| {
200                            sp_blockchain::Error::Backend(format!(
201                                "Domain block header for #{parent_domain_hash:?} not found",
202                            ))
203                        })?;
204                    parent_domain_header
205                        .digest()
206                        .convert_first(DigestItem::as_consensus_block_info)
207                        .expect("Domain block header must have the consensus block info digest")
208                };
209                let maybe_parent_bad_receipt = self
210                    .bad_receipts_cache
211                    .get(&parent_domain_number)
212                    .and_then(|bad_receipts_at| bad_receipts_at.get(&parent_block_consensus_hash));
213                match maybe_parent_bad_receipt {
214                    Some(parent_bad_receipt) => {
215                        receipt.parent_domain_block_receipt_hash =
216                            parent_bad_receipt.hash::<HeaderHashingFor<Block::Header>>();
217                    }
218                    // The parent receipt is not a bad receipt so even we modify this field to a random
219                    // value, the receipt will be rejected by the consensus node directly thus just skip
220                    None => return Ok(()),
221                }
222            }
223            // NOTE: Not need to modify the bundle `extrinsics_root` or the lenght of `inboxed_bundles`
224            // since the consensus runtime will perform the these checks and reject the bundle directly
225            BadReceiptType::InboxedBundle => {
226                let mismatch_index = random_seed as usize % receipt.inboxed_bundles.len();
227                receipt.inboxed_bundles[mismatch_index].bundle = if random_seed % 2 == 0 {
228                    BundleValidity::Valid(Default::default())
229                } else {
230                    let extrincis_index = random_seed % 2;
231                    let invalid_bundle_type = match random_seed as usize % 5 {
232                        0 => InvalidBundleType::UndecodableTx(extrincis_index),
233                        1 => InvalidBundleType::OutOfRangeTx(extrincis_index),
234                        2 => InvalidBundleType::IllegalTx(extrincis_index),
235                        3 => InvalidBundleType::InherentExtrinsic(extrincis_index),
236                        4 => InvalidBundleType::InvalidBundleWeight,
237                        _ => unreachable!(),
238                    };
239                    BundleValidity::Invalid(invalid_bundle_type)
240                }
241            }
242        }
243
244        // Add the bad receipt to cache and remove the oldest receipt from cache
245        self.bad_receipts_cache
246            .entry(receipt.domain_block_number)
247            .or_default()
248            .insert(receipt.consensus_block_hash, receipt.clone());
249        if self.bad_receipts_cache.len() as u32 > MAX_BAD_RECEIPT_CACHE {
250            self.bad_receipts_cache.pop_first();
251        }
252
253        Ok(())
254    }
255
256    #[allow(clippy::modulo_one)]
257    fn make_bundle_invalid(
258        &self,
259        opaque_bundle: &mut OpaqueBundleFor<Block, CBlock>,
260    ) -> Result<(), Box<dyn Error>> {
261        let random_seed = Random::seed();
262        let invalid_bundle_type = match random_seed % 4 {
263            0 => InvalidBundleType::UndecodableTx(0),
264            1 => InvalidBundleType::IllegalTx(0),
265            2 => InvalidBundleType::InherentExtrinsic(0),
266            3 => InvalidBundleType::InvalidBundleWeight,
267            // TODO: `OutOfRangeTx` invalid bundle is tricky to simulate because the
268            // tx range is dynamically change based on the `proof_of_election.vrf_hash`
269            // 1 => InvalidBundleType::OutOfRangeTx(0),
270            _ => unreachable!(),
271        };
272        tracing::info!(
273            ?invalid_bundle_type,
274            "Generate invalid bundle, receipt domain block {}#{}",
275            opaque_bundle.receipt().domain_block_number,
276            opaque_bundle.receipt().domain_block_hash,
277        );
278
279        let invalid_tx = match invalid_bundle_type {
280            InvalidBundleType::UndecodableTx(_) => OpaqueExtrinsic::default(),
281            // The duplicated extrinsic will be illegal due to `Nonce` if it is a signed extrinsic
282            InvalidBundleType::IllegalTx(_) if !opaque_bundle.extrinsics.is_empty() => {
283                opaque_bundle.extrinsics[0].clone()
284            }
285            InvalidBundleType::InherentExtrinsic(_) => {
286                let inherent_tx = self
287                    .domain_client
288                    .runtime_api()
289                    .construct_timestamp_extrinsic(
290                        self.domain_client.info().best_hash,
291                        Default::default(),
292                    )?;
293                OpaqueExtrinsic::from_bytes(&inherent_tx.encode())
294                    .expect("We have just encoded a valid extrinsic; qed")
295            }
296            InvalidBundleType::InvalidBundleWeight => {
297                opaque_bundle.sealed_header.header.estimated_bundle_weight =
298                    Weight::from_all(123456);
299                return Ok(());
300            }
301            _ => return Ok(()),
302        };
303
304        opaque_bundle.extrinsics.push(invalid_tx);
305
306        Ok(())
307    }
308
309    fn reseal_bundle(
310        &self,
311        opaque_bundle: &mut OpaqueBundleFor<Block, CBlock>,
312        operator_signing_key: &OperatorPublicKey,
313    ) -> Result<(), Box<dyn Error>> {
314        opaque_bundle.sealed_header.header.bundle_extrinsics_root =
315            HeaderHashingFor::<Block::Header>::ordered_trie_root(
316                opaque_bundle
317                    .extrinsics
318                    .iter()
319                    .map(|xt| xt.encode())
320                    .collect(),
321                sp_core::storage::StateVersion::V1,
322            );
323
324        let pre_hash = opaque_bundle.sealed_header.pre_hash();
325        opaque_bundle.sealed_header.signature = {
326            let s = self
327                .keystore
328                .sr25519_sign(
329                    OperatorPublicKey::ID,
330                    operator_signing_key.as_ref(),
331                    pre_hash.as_ref(),
332                )?
333                .expect("The malicious operator's key pair must exist");
334            OperatorSignature::decode(&mut s.as_ref())
335                .expect("Deconde as OperatorSignature must succeed")
336        };
337        Ok(())
338    }
339}