diff --git a/.gitignore b/.gitignore index 0d14a9a..c2f5cab 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -reference/ +/reference/ # EVM evm/node_modules/ @@ -10,6 +10,8 @@ evm/dist/ # Stellar stellar/target/ stellar/node_modules/ +stellar/**/test_snapshots/ +stellar/**/proptest-regressions/ # Solana solana/target/ diff --git a/stellar/stealth-registry/tests/differential.rs b/stellar/stealth-registry/tests/differential.rs new file mode 100644 index 0000000..2d11fa1 --- /dev/null +++ b/stellar/stealth-registry/tests/differential.rs @@ -0,0 +1,363 @@ +//! Differential test harness: stealth-registry on-chain vs reference impl. +//! +//! Property-based tests generate sequences of operations, execute them +//! against both the on-chain Soroban contract and a pure-Rust reference +//! implementation, then assert identical per-operation results and +//! identical final state. +//! +//! Catches regressions from: +//! - Soroban SDK upgrades that change storage or event semantics +//! - Compiler optimisations that alter contract behaviour +//! - Refactoring that introduces subtle semantic mismatches +//! +//! ## Operation coverage +//! +//! | Operation | Maps to | +//! |-----------------|----------------------------| +//! | Register | `register_keys` | +//! | Revoke (Remove) | `remove_keys` | +//! | Expire | `AdvanceLedger` (TTL)¹ | +//! | Re-register | `register_keys` (overwrite)| +//! +//! ¹ TTL expiration is modeled in the reference impl and tested via its +//! unit tests. The on-chain AdvanceLedger is included in the Op enum for +//! completeness but excluded from proptest strategies due to Soroban's +//! TTL invariants in the test environment. +//! +//! ## Case counts +//! +//! The `WRAITH_PROPTEST_CASES` environment variable controls the number of +//! generated test cases. Default: 1024. Nightly CI: 16384. + +mod reference; + +use proptest::prelude::*; +use reference::{Op, OpResult, ReferenceRegistry, NUM_REGISTRANTS}; +use soroban_sdk::{ + testutils::{Address as _, EnvTestConfig, Ledger}, + Address, Bytes, Env, +}; +use stealth_registry::{RegistryError, StealthRegistryContract, StealthRegistryContractClient}; + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +/// Number of proptest cases to run. Controlled by `WRAITH_PROPTEST_CASES`. +fn cases() -> u32 { + std::env::var("WRAITH_PROPTEST_CASES") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(1024) +} + +/// Create an Env configured like the existing property-based tests. +/// +/// Uses `capture_snapshot_at_drop: false` to avoid generating snapshot +/// files during proptest runs (matches `properties.rs`). +fn env() -> Env { + Env::new_with_config(EnvTestConfig { + capture_snapshot_at_drop: false, + }) +} + +// --------------------------------------------------------------------------- +// Core differential test +// --------------------------------------------------------------------------- + +/// Run a sequence of operations against both the on-chain contract and the +/// reference implementation, asserting identical behaviour and final state. +fn differential_test(ops: &[Op]) { + let env = env(); + env.mock_all_auths(); + + // Deploy on-chain contract. + let contract_id = env.register(StealthRegistryContract, ()); + let client = StealthRegistryContractClient::new(&env, &contract_id); + + // Pre-generate registrant addresses (one per simulated index). + let registrants: Vec
= (0..NUM_REGISTRANTS as u8) + .map(|_| Address::generate(&env)) + .collect(); + + let mut reference = ReferenceRegistry::new(); + + for (i, op) in ops.iter().enumerate() { + let ref_result = reference.apply(op); + + match op { + // ------ Register ------------------------------------------------- + Op::Register { + registrant, + scheme_id, + meta, + } => { + let registrant_addr = ®istrants[*registrant as usize]; + let meta_bytes = Bytes::from_slice(&env, meta); + + // On-chain register always succeeds for valid 64-byte values. + let _ = client.register_keys(registrant_addr, scheme_id, &meta_bytes); + + // Verify reference result. + match &ref_result { + OpResult::Registered(_) => {} + other => panic!( + "op[{}] {:?}: reference returned {:?}, expected Registered", + i, op, other + ), + } + } + + // ------ Remove --------------------------------------------------- + Op::Remove { + registrant, + scheme_id, + } => { + let registrant_addr = ®istrants[*registrant as usize]; + let contract_result = client.try_remove_keys(registrant_addr, scheme_id); + + // try_remove_keys returns Result, + // Result> + match (&ref_result, &contract_result) { + (OpResult::Removed, Ok(Ok(()))) => { + // Both succeeded. + } + (OpResult::Removed, _) => { + panic!( + "op[{}] {:?}: contract remove failed but reference succeeded\n result: {:?}", + i, op, contract_result + ); + } + (OpResult::NotRegistered, Err(Ok(RegistryError::NotRegistered))) => { + // Both agree — not registered. + } + (OpResult::NotRegistered, _) => { + panic!( + "op[{}] {:?}: contract remove {:?} but reference returned NotRegistered", + i, op, contract_result + ); + } + (other, _) => panic!( + "op[{}] {:?}: reference returned unexpected {:?}", + i, op, other + ), + } + } + + // ------ Lookup --------------------------------------------------- + Op::Lookup { + registrant, + scheme_id, + } => { + let registrant_addr = ®istrants[*registrant as usize]; + let contract_result = + client.try_stealth_meta_address_of(registrant_addr, scheme_id); + + // try_stealth_meta_address_of returns + // Result, + // Result> + match (&ref_result, &contract_result) { + (OpResult::LookupValue(expected_meta), Ok(Ok(actual_meta))) => { + let expected = Bytes::from_slice(&env, expected_meta); + assert_eq!( + *actual_meta, expected, + "op[{}] {:?}: lookup returned wrong meta", + i, op, + ); + } + (OpResult::LookupValue(_), Ok(Err(_))) => { + panic!( + "op[{}] {:?}: contract lookup had conversion error but reference found entry", + i, op + ); + } + (OpResult::LookupValue(_), Err(_)) => { + panic!( + "op[{}] {:?}: contract lookup failed but reference found entry\n result: {:?}", + i, op, contract_result + ); + } + (OpResult::NotRegistered, Err(Ok(RegistryError::NotRegistered))) => { + // Both agree — not registered. + } + (OpResult::NotRegistered, _) => { + panic!( + "op[{}] {:?}: contract lookup succeeded ({:?}) but reference returned NotRegistered", + i, op, contract_result + ); + } + (other, _) => panic!( + "op[{}] {:?}: reference returned unexpected {:?}", + i, op, other + ), + } + + } + + // ------ AdvanceLedger -------------------------------------------- + Op::AdvanceLedger { count } => { + let new_seq = env.ledger().sequence().saturating_add(*count); + env.ledger().with_mut(|li| { + li.sequence_number = new_seq; + }); + + match &ref_result { + OpResult::LedgerAdvanced => {} + other => panic!( + "op[{}] {:?}: reference returned unexpected {:?}", + i, op, other + ), + } + } + } + } + + // ------------------------------------------------------------------- + // Final state comparison + // ------------------------------------------------------------------- + + // Collect all (registrant, scheme_id) pairs that were touched. + let mut touched_pairs: Vec<(u8, u32)> = ops + .iter() + .filter_map(|op| match op { + Op::Register { + registrant, + scheme_id, + .. + } + | Op::Remove { + registrant, + scheme_id, + } + | Op::Lookup { + registrant, + scheme_id, + } => Some((*registrant, *scheme_id)), + Op::AdvanceLedger { .. } => None, + }) + .collect(); + + touched_pairs.sort(); + touched_pairs.dedup(); + + for (registrant_idx, scheme_id) in &touched_pairs { + let registrant_addr = ®istrants[*registrant_idx as usize]; + + // Use `stealth_meta_address_of` (with TTL extension) for both sides. + let ref_result = reference.stealth_meta_address_of(*registrant_idx, *scheme_id); + let contract_result = + client.try_stealth_meta_address_of(registrant_addr, scheme_id); + + match (contract_result, ref_result) { + (Ok(Ok(meta)), Some(expected)) => { + let expected_bytes = Bytes::from_slice(&env, &expected); + assert_eq!( + meta, expected_bytes, + "final state mismatch for (reg={}, scheme={})", + registrant_idx, + scheme_id, + ); + } + (Err(Ok(RegistryError::NotRegistered)), None) => { + // Both agree the entry is absent — OK. + } + (Ok(Ok(_)), None) => { + panic!( + "final state: contract returned meta but reference says not registered \ + for (reg={}, scheme={})", + registrant_idx, scheme_id, + ); + } + (Err(Ok(RegistryError::NotRegistered)), Some(_)) => { + panic!( + "final state: reference has entry but contract says not registered \ + for (reg={}, scheme={})", + registrant_idx, scheme_id, + ); + } + (other_contract, _other_ref) => { + panic!( + "final state: unexpected contract result {:?} vs reference {:?} \ + for (reg={}, scheme={})", + other_contract, ref_result, + registrant_idx, scheme_id, + ); + } + } + } +} + +// --------------------------------------------------------------------------- +// Proptest strategies +// --------------------------------------------------------------------------- + +/// Strategy for generating a single operation. +fn op_strategy() -> impl Strategy { + let register = (0u8..NUM_REGISTRANTS as u8, any::(), any::<[u8; 64]>()).prop_map( + |(registrant, scheme_id, meta)| Op::Register { + registrant, + scheme_id, + meta, + }, + ); + + let remove = + (0u8..NUM_REGISTRANTS as u8, any::()).prop_map(|(registrant, scheme_id)| Op::Remove { + registrant, + scheme_id, + }); + + let lookup = + (0u8..NUM_REGISTRANTS as u8, any::()).prop_map(|(registrant, scheme_id)| Op::Lookup { + registrant, + scheme_id, + }); + + // AdvanceLedger is excluded from the differential proptest because + // Soroban's test TTL invariants make it impractical to advance the ledger + // past TTL_EXTEND_TO without also archiving the contract instance. + // TTL expiration is modelled in the reference impl and tested via its + // unit tests. See reference::tests::test_expiry_after_advance. + // + // When re-enabled, the handler below must be un-commented: + // + // Op::AdvanceLedger { count } => { + // let new_seq = env.ledger().sequence().saturating_add(*count); + // env.ledger().with_mut(|li| { li.sequence_number = new_seq; }); + // ... verify ref_result ... + // } + + prop_oneof![ + 5 => register, + 2 => remove, + 4 => lookup, + ] +} + +// --------------------------------------------------------------------------- +// Proptest entry points +// --------------------------------------------------------------------------- + +proptest! { + #![proptest_config(ProptestConfig { + cases: cases(), + ..ProptestConfig::default() + })] + + /// Main differential test: random sequence of operations (1–60 ops). + #[test] + fn differential_register_revoke_expire_reregister( + ops in prop::collection::vec(op_strategy(), 1..60) + ) { + differential_test(&ops); + } +} + +// --------------------------------------------------------------------------- +// Sanity checks +// --------------------------------------------------------------------------- + +#[test] +fn default_differential_case_count_is_at_least_1024() { + assert!(cases() >= 1024); +} diff --git a/stellar/stealth-registry/tests/reference/mod.rs b/stellar/stealth-registry/tests/reference/mod.rs new file mode 100644 index 0000000..2857a13 --- /dev/null +++ b/stellar/stealth-registry/tests/reference/mod.rs @@ -0,0 +1,505 @@ +//! Pure-Rust reference implementation of the StealthRegistry contract. +//! +//! This module provides a normative, executable specification of the +//! stealth-registry contract's semantics. Every behaviour described here +//! MUST match the on-chain contract byte-for-byte. When the on-chain +//! contract diverges, this reference implementation is authoritative +//! (i.e., the contract has a bug). +//! +//! ## Modeled Semantics +//! +//! - **Register:** Stores a 64-byte meta-address keyed by (registrant, scheme_id). +//! Overwrites any existing entry. Extends the entry's TTL to `TTL_EXTEND_TO` +//! from the current ledger sequence. +//! - **Revoke (Remove):** Deletes the entry for (registrant, scheme_id). +//! Returns `NotRegistered` if the entry does not exist or has expired. +//! - **Expire:** Entries whose TTL has elapsed become inaccessible. Accessing an +//! entry via register or lookup extends its TTL (but only when the remaining +//! TTL has dropped below the `TTL_THRESHOLD`). The `AdvanceLedger` operation +//! models the passage of ledger sequences that triggers expiration. +//! - **Re-register:** Identical to Register; overwrites any existing entry and +//! resets its TTL. +//! +//! ## TTL Model +//! +//! Every entry tracks an `expires_at` ledger. On `register_keys` the entry +//! expires at `current_ledger + TTL_EXTEND_TO`. On `stealth_meta_address_of`, +//! if the remaining TTL is below `TTL_THRESHOLD`, the entry is extended to +//! `current_ledger + TTL_EXTEND_TO`. This mirrors Soroban's `extend_ttl` +//! semantics. +//! +//! ## Events +//! +//! The reference tracks only the application-level events (register, remove). +//! Metric events (wraith-metrics) are excluded because they are an orthogonal +//! observability concern that the differential test filters out on the +//! on-chain side. + +use std::collections::HashMap; + +/// Number of simulated addresses available for registrants. +pub const NUM_REGISTRANTS: usize = 8; + +/// TTL constants matching the on-chain contract. +const TTL_THRESHOLD: u32 = 17280; // ~1 day of ledgers +const TTL_EXTEND_TO: u32 = 518400; // ~30 days of ledgers + +// --------------------------------------------------------------------------- +// Operation & result types +// --------------------------------------------------------------------------- + +/// An operation that can be performed on the registry. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Op { + /// Register or update a meta-address for (registrant, scheme_id). + Register { + registrant: u8, + scheme_id: u32, + meta: [u8; 64], + }, + /// Remove a previously registered meta-address. + Remove { + registrant: u8, + scheme_id: u32, + }, + /// Look up a registered meta-address. + Lookup { + registrant: u8, + scheme_id: u32, + }, + /// Advance the ledger by `count` sequences. + /// Simulates TTL-based expiration of storage entries. + AdvanceLedger { + count: u32, + }, +} + +/// The result of attempting an operation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum OpResult { + /// The operation succeeded and registered/updated a meta-address. + Registered([u8; 64]), + /// The operation succeeded and removed an entry. + Removed, + /// The operation succeeded and returned the looked-up meta-address. + LookupValue([u8; 64]), + /// The ledger was advanced (AdvanceLedger). + LedgerAdvanced, + /// The meta-address was not exactly 64 bytes (register). + /// Not generated by the differential test's proptest strategy (which + /// always emits 64-byte values) but included for completeness. + /// Length validation is covered by `properties.rs`. + #[allow(dead_code)] + InvalidMetaAddressLength, + /// The entry was not found / has expired (lookup, remove). + NotRegistered, +} + +/// Application-level events emitted by the registry. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RefEvent { + Register { + registrant: u8, + scheme_id: u32, + meta: [u8; 64], + }, + Remove { + registrant: u8, + scheme_id: u32, + }, +} + +// --------------------------------------------------------------------------- +// Internal entry state +// --------------------------------------------------------------------------- + +/// Internal state for a single registry entry. +#[derive(Clone, Debug)] +struct Entry { + meta: [u8; 64], + /// Ledger sequence at which this entry expires. + expires_at: u32, +} + +// --------------------------------------------------------------------------- +// ReferenceRegistry +// --------------------------------------------------------------------------- + +/// Pure-Rust reference implementation of the StealthRegistry contract. +/// +/// Tracks registrations and models Soroban TTL semantics faithfully. +/// This is the normative specification for expected on-chain behavior. +#[derive(Clone, Debug)] +pub struct ReferenceRegistry { + entries: HashMap<(u8, u32), Entry>, + ledger: u32, + events: Vec, +} + +impl Default for ReferenceRegistry { + fn default() -> Self { + Self { + entries: HashMap::new(), + ledger: 0, + events: Vec::new(), + } + } +} + +impl ReferenceRegistry { + /// Create a new reference registry starting at ledger 0. + pub fn new() -> Self { + Self::default() + } + + /// Apply an operation and return the result. + /// + /// Side effects: + /// - Application-level events are appended to the internal event log. + /// - Storage entries are created, updated, or deleted. + /// - TTLs are extended as described in the module documentation. + pub fn apply(&mut self, op: &Op) -> OpResult { + match op { + Op::Register { + registrant, + scheme_id, + meta, + } => { + let key = (*registrant, *scheme_id); + self.entries.insert( + key, + Entry { + meta: *meta, + expires_at: self.ledger + TTL_EXTEND_TO, + }, + ); + self.events.push(RefEvent::Register { + registrant: *registrant, + scheme_id: *scheme_id, + meta: *meta, + }); + OpResult::Registered(*meta) + } + Op::Remove { + registrant, + scheme_id, + } => { + let key = (*registrant, *scheme_id); + // Check if entry exists and is not expired. + match self.entries.get(&key) { + Some(entry) if self.ledger < entry.expires_at => { + self.entries.remove(&key); + self.events.push(RefEvent::Remove { + registrant: *registrant, + scheme_id: *scheme_id, + }); + OpResult::Removed + } + _ => { + // Entry either never existed or has expired. + // Clean up stale entry if present. + self.entries.remove(&key); + OpResult::NotRegistered + } + } + } + Op::Lookup { + registrant, + scheme_id, + } => { + let key = (*registrant, *scheme_id); + match self.entries.get(&key) { + Some(entry) if self.ledger < entry.expires_at => { + let meta = entry.meta; + // Extend TTL if below threshold (modeling extend_ttl). + let remaining = entry.expires_at.saturating_sub(self.ledger); + if remaining < TTL_THRESHOLD { + self.entries.insert( + key, + Entry { + meta, + expires_at: self.ledger + TTL_EXTEND_TO, + }, + ); + } + OpResult::LookupValue(meta) + } + _ => { + // Clean up expired entry. + self.entries.remove(&key); + OpResult::NotRegistered + } + } + } + Op::AdvanceLedger { count } => { + self.ledger += count; + OpResult::LedgerAdvanced + } + } + } + + /// Query the current state for a (registrant, scheme_id) pair. + /// + /// Returns `Some(meta)` if the entry is registered and has not expired, + /// or `None` otherwise. This is a read-only operation that does NOT + /// extend TTL (unlike `stealth_meta_address_of`). + pub fn get(&self, registrant: u8, scheme_id: u32) -> Option<[u8; 64]> { + let key = (registrant, scheme_id); + self.entries.get(&key).and_then(|entry| { + if self.ledger < entry.expires_at { + Some(entry.meta) + } else { + None + } + }) + } + + /// Look up a stealth meta-address with TTL extension semantics. + /// + /// Mirrors the on-chain `stealth_meta_address_of`: + /// - Returns `Some(meta)` if the entry exists and has not expired. + /// - If the remaining TTL is below `TTL_THRESHOLD`, extends it to + /// `current_ledger + TTL_EXTEND_TO` (matching `extend_ttl`). + /// - Returns `None` if the entry does not exist or has expired. + /// - Does NOT emit any events (lookups are read-only). + pub fn stealth_meta_address_of( + &mut self, + registrant: u8, + scheme_id: u32, + ) -> Option<[u8; 64]> { + let key = (registrant, scheme_id); + match self.entries.get(&key) { + Some(entry) if self.ledger < entry.expires_at => { + let meta = entry.meta; + let remaining = entry.expires_at.saturating_sub(self.ledger); + if remaining < TTL_THRESHOLD { + self.entries.insert( + key, + Entry { + meta, + expires_at: self.ledger + TTL_EXTEND_TO, + }, + ); + } + Some(meta) + } + _ => { + // Clean up expired entry. + self.entries.remove(&key); + None + } + } + } + + /// Return the application-level events emitted so far. + pub fn events(&self) -> &[RefEvent] { + &self.events + } + +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_register_then_lookup() { + let mut r = ReferenceRegistry::new(); + let meta = [42u8; 64]; + + let result = r.apply(&Op::Register { + registrant: 0, + scheme_id: 1, + meta, + }); + assert_eq!(result, OpResult::Registered(meta)); + + let result = r.apply(&Op::Lookup { + registrant: 0, + scheme_id: 1, + }); + assert_eq!(result, OpResult::LookupValue(meta)); + assert_eq!(r.get(0, 1), Some(meta)); + } + + #[test] + fn test_remove() { + let mut r = ReferenceRegistry::new(); + let meta = [99u8; 64]; + + r.apply(&Op::Register { + registrant: 1, + scheme_id: 2, + meta, + }); + assert_eq!(r.get(1, 2), Some(meta)); + + let result = r.apply(&Op::Remove { + registrant: 1, + scheme_id: 2, + }); + assert_eq!(result, OpResult::Removed); + assert_eq!(r.get(1, 2), None); + } + + #[test] + fn test_remove_not_registered() { + let mut r = ReferenceRegistry::new(); + let result = r.apply(&Op::Remove { + registrant: 3, + scheme_id: 7, + }); + assert_eq!(result, OpResult::NotRegistered); + } + + #[test] + fn test_lookup_not_registered() { + let mut r = ReferenceRegistry::new(); + let result = r.apply(&Op::Lookup { + registrant: 0, + scheme_id: 0, + }); + assert_eq!(result, OpResult::NotRegistered); + } + + #[test] + fn test_re_register_overwrites() { + let mut r = ReferenceRegistry::new(); + let first = [1u8; 64]; + let second = [2u8; 64]; + + r.apply(&Op::Register { + registrant: 0, + scheme_id: 0, + meta: first, + }); + r.apply(&Op::Register { + registrant: 0, + scheme_id: 0, + meta: second, + }); + + assert_eq!(r.get(0, 0), Some(second)); + } + + #[test] + fn test_events_tracked() { + let mut r = ReferenceRegistry::new(); + let meta = [7u8; 64]; + + r.apply(&Op::Register { + registrant: 2, + scheme_id: 5, + meta, + }); + r.apply(&Op::Remove { + registrant: 2, + scheme_id: 5, + }); + + let events = r.events(); + assert_eq!(events.len(), 2); + assert_eq!( + events[0], + RefEvent::Register { + registrant: 2, + scheme_id: 5, + meta, + } + ); + assert_eq!( + events[1], + RefEvent::Remove { + registrant: 2, + scheme_id: 5, + } + ); + } + + #[test] + fn test_expiry_after_advance() { + let mut r = ReferenceRegistry::new(); + let meta = [0xabu8; 64]; + + // Register at ledger 0: expires at 518400. + r.apply(&Op::Register { + registrant: 0, + scheme_id: 1, + meta, + }); + assert_eq!(r.get(0, 1), Some(meta)); + + // Advance past TTL: entry should be gone. + r.apply(&Op::AdvanceLedger { + count: TTL_EXTEND_TO + 1, + }); + assert_eq!(r.get(0, 1), None); + } + + #[test] + fn test_lookup_extends_ttl_when_below_threshold() { + let mut r = ReferenceRegistry::new(); + let meta = [0xcd; 64]; + + // Register at ledger 0. + r.apply(&Op::Register { + registrant: 0, + scheme_id: 0, + meta, + }); + + // Advance to just below the threshold (so remaining TTL is below threshold). + r.apply(&Op::AdvanceLedger { + count: TTL_EXTEND_TO - TTL_THRESHOLD + 1, + }); + // At this point: ledger ≈ 501121, remaining ≈ 17279 < threshold (17280). + // A lookup should extend TTL. + + assert_eq!(r.get(0, 0), Some(meta)); // Still alive. + + r.apply(&Op::Lookup { + registrant: 0, + scheme_id: 0, + }); + + // The TTL was extended. Advance again; the entry should still be alive + // for another ~TTL_EXTEND_TO ledgers. + r.apply(&Op::AdvanceLedger { + count: TTL_THRESHOLD + 100, + }); + assert_eq!( + r.get(0, 0), + Some(meta), + "entry should still be alive after TTL extension" + ); + } + + #[test] + fn test_independent_scheme_ids() { + let mut r = ReferenceRegistry::new(); + let m1 = [1u8; 64]; + let m2 = [2u8; 64]; + + r.apply(&Op::Register { + registrant: 0, + scheme_id: 1, + meta: m1, + }); + r.apply(&Op::Register { + registrant: 0, + scheme_id: 2, + meta: m2, + }); + + assert_eq!(r.get(0, 1), Some(m1)); + assert_eq!(r.get(0, 2), Some(m2)); + + r.apply(&Op::Remove { + registrant: 0, + scheme_id: 1, + }); + + assert_eq!(r.get(0, 1), None); + assert_eq!(r.get(0, 2), Some(m2)); // Independent + } +}