Skip to main content

blokli_client/client/testing/
mod.rs

1//! In-memory Blokli client implementation for downstream tests.
2//!
3//! This module is available with the `testing` feature. It provides [`BlokliTestClient`], a state-backed client that
4//! implements the same query, subscription, and transaction traits as [`BlokliClient`](crate::BlokliClient). Use it
5//! when library consumers need deterministic tests without running a Blokli service.
6//!
7//! The client starts from a [`BlokliTestState`]. Submitted transactions are passed to a
8//! [`BlokliTestStateMutator`], which may update that state. The client then broadcasts account, channel, safe,
9//! service-registry, and ticket-parameter changes to active subscriptions.
10//!
11//! This is a test double, not a byte-for-byte Blokli server emulator. It enforces basic consistency checks and keeps
12//! subscription behavior close to the public traits, but callers remain responsible for modeling the state transitions
13//! they care about in their mutator.
14
15use std::{
16    ops::Div,
17    sync::Arc,
18    time::{Duration, SystemTime},
19};
20
21use async_broadcast::TrySendError;
22use futures::{Stream, StreamExt};
23use futures_time::{stream::StreamExt as TimeStreamExt, time::Duration as Duration2};
24use hopr_types::{crypto::types::Hash, primitive::prelude::HoprBalance as PrimitiveHoprBalance};
25use indexmap::IndexMap;
26
27use crate::{
28    api::{types::*, v1::graphql::services::service_type_name, *},
29    errors::{BlokliClientError, ErrorKind, InternalTxError, TrackingErrorKind},
30};
31
32fn serialize_as_empty_map<K, V, S>(_: &IndexMap<K, V>, serializer: S) -> std::result::Result<S::Ok, S::Error>
33where
34    K: serde::Serialize,
35    V: serde::Serialize,
36    S: serde::Serializer,
37{
38    serde::Serialize::serialize(&IndexMap::<K, V>::new(), serializer)
39}
40
41fn default_service_registry_config() -> ServiceRegistryConfig {
42    ServiceRegistryConfig {
43        type_registration_fee: "0 wxHOPR".into(),
44        node_safe_registry: "0x0000000000000000000000000000000000000000".into(),
45    }
46}
47
48/// In-memory state served by [`BlokliTestClient`].
49///
50/// Fields are public so tests can build fixtures directly. Maps are keyed by the same identifiers used by the public
51/// client responses, typically hex-encoded addresses or ids. [`BlokliTestState::default`] provides a small coherent
52/// baseline suitable for tests that only need to override a few fields.
53#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
54pub struct BlokliTestState {
55    /// Contains KeyID -> Account
56    pub accounts: IndexMap<u32, Account>,
57    /// Contains native balances for addresses.
58    pub native_balances: IndexMap<String, NativeBalance>,
59    /// Contains token balances for addresses.
60    pub token_balances: IndexMap<String, HoprBalance>,
61    /// Contains safe allowances for addresses.
62    pub safe_allowances: IndexMap<String, SafeHoprAllowance>,
63    /// Contains deployed Safes for addresses.
64    pub deployed_safes: IndexMap<String, Safe>,
65    /// Ticket redemption statistics per Safe address
66    pub safe_redeem_stats: IndexMap<String, RedeemedStats>,
67    /// Contains transaction counts for addresses.
68    pub tx_counts: IndexMap<String, u64>,
69    /// Contains ChannelId -> Channel.
70    pub channels: IndexMap<String, Channel>,
71    /// Contains service registry entries, keyed by [`BlokliTestState::service_entry_key`].
72    pub services: IndexMap<String, ServiceEntry>,
73    /// Contains service type configuration, keyed by [`ServiceTypeInfo::service_type`].
74    pub service_types: IndexMap<String, ServiceTypeInfo>,
75    /// Contains the registry-wide type registration fee and node-safe registry pointer.
76    #[serde(default = "default_service_registry_config")]
77    pub service_registry_config: ServiceRegistryConfig,
78    /// Contains chain info.
79    pub chain_info: ChainInfo,
80    /// Version of the Blokli server.
81    pub version: String,
82    /// Health of the Blokli server.
83    pub health: String,
84    /// Active transactions.
85    ///
86    /// This field is transient and not serialized.
87    // Always serialize as empty, because the data are non-deterministic and do not make sense to compare.
88    #[serde(serialize_with = "serialize_as_empty_map")]
89    pub active_txs: IndexMap<TxId, Transaction>,
90}
91
92impl PartialEq for BlokliTestState {
93    fn eq(&self, other: &Self) -> bool {
94        // Skip active_txs because they are non-deterministic.
95        self.accounts == other.accounts
96            && self.deployed_safes == other.deployed_safes
97            && self.native_balances == other.native_balances
98            && self.token_balances == other.token_balances
99            && self.safe_allowances == other.safe_allowances
100            && self.safe_redeem_stats == other.safe_redeem_stats
101            && self.tx_counts == other.tx_counts
102            && self.channels == other.channels
103            && self.services == other.services
104            && self.service_types == other.service_types
105            && self.service_registry_config == other.service_registry_config
106            && self.chain_info == other.chain_info
107            && self.version == other.version
108            && self.health == other.health
109    }
110}
111
112impl Default for BlokliTestState {
113    fn default() -> Self {
114        Self {
115            accounts: Default::default(),
116            native_balances: Default::default(),
117            token_balances: Default::default(),
118            safe_allowances: Default::default(),
119            deployed_safes: Default::default(),
120            safe_redeem_stats: Default::default(),
121            tx_counts: Default::default(),
122            channels: Default::default(),
123            services: Default::default(),
124            service_types: Default::default(),
125            service_registry_config: default_service_registry_config(),
126            chain_info: ChainInfo {
127                channel_closure_grace_period: Uint64("300".into()),
128                channel_dst: Some("0000000000000000000000000000000000000000000000000000000000000000".into()),
129                block_number: 1,
130                chain_id: 100,
131                gas_price: Some("1000000000".into()),
132                ledger_dst: Some("0000000000000000000000000000000000000000000000000000000000000000".into()),
133                max_fee_per_gas: Some("3000000000".into()),
134                max_priority_fee_per_gas: Some("100000000".into()),
135                min_ticket_winning_probability: 1.0,
136                key_binding_fee: TokenValueString("0.01 wxHOPR".into()),
137                safe_registry_dst: Some("0000000000000000000000000000000000000000000000000000000000000000".into()),
138                ticket_price: TokenValueString("1 wxHOPR".into()),
139                network: "jura".into(),
140                contract_addresses: ContractAddressMap(
141                    r#"
142                {
143                    "announcements": "0xf1c143B1bA20C7606d56aA2FA94502D25744b982",
144                    "channels": "0x77C9414043d27fdC98A6A2d73fc77b9b383092a7",
145                    "module_implementation": "0x32863c4974fBb6253E338a0cb70C382DCeD2eFCb",
146                    "node_safe_registry": "0x4F7C7dE3BA2B29ED8B2448dF2213cA43f94E45c0",
147                    "node_stake_factory": "0x791d190b2c95397F4BcE7bD8032FD67dCEA7a5F2",
148                    "node_safe_migration": "0x0000000000000000000000000000000000000000",
149                    "service_registry": "0x9A676e781A523b5d0C0e43731313A708CB607508",
150                    "token": "0xD4fdec44DB9D44B8f2b6d529620f9C0C7066A2c1",
151                    "ticket_price_oracle": "0x442df1d946303fB088C9377eefdaeA84146DA0A6",
152                    "winning_probability_oracle": "0xC15675d4CCa538D91a91a8D3EcFBB8499C3B0471",
153                    "xhopr_token": "0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0"
154                }"#
155                    .into(),
156                ),
157                expected_block_time: Uint64("5".into()),
158                finality: Uint64("3".into()),
159            },
160
161            version: "1".to_string(),
162            health: "OK".to_string(),
163            active_txs: Default::default(),
164        }
165    }
166}
167
168impl BlokliTestState {
169    fn safe_matches_owner(safe: &Safe, owner_hex: &str) -> bool {
170        safe.chain_key == owner_hex || safe.owners.iter().any(|owner| owner == owner_hex)
171    }
172
173    /// Builds the [`services`](BlokliTestState::services) key for a service type and node address.
174    ///
175    /// The key is `"<service type>/<node address>"`, where the service type is written the way Blokli renders it -
176    /// the ASCII name, or `0x`-prefixed hex - and the node address is hex, with or without a `0x` prefix.
177    pub fn service_entry_key(service_type: &str, node: &ChainAddress) -> String {
178        format!("{service_type}/{}", hex::encode(node))
179    }
180
181    /// Convenience method to return a reference to the [`ServiceEntry`] of a node for a service type.
182    pub fn get_service_entry(&self, service_type: &ServiceTypeId, node: &ChainAddress) -> Option<&ServiceEntry> {
183        self.services
184            .values()
185            .find(|entry| service_type_matches(&entry.service_type, service_type) && hex_matches(&entry.node, node))
186    }
187
188    /// Convenience method to return a mutable reference to the [`ServiceEntry`] of a node for a service type.
189    pub fn get_service_entry_mut(
190        &mut self,
191        service_type: &ServiceTypeId,
192        node: &ChainAddress,
193    ) -> Option<&mut ServiceEntry> {
194        self.services
195            .values_mut()
196            .find(|entry| service_type_matches(&entry.service_type, service_type) && hex_matches(&entry.node, node))
197    }
198
199    /// Convenience method to return a reference to the [`ServiceTypeInfo`] of a service type.
200    pub fn get_service_type(&self, service_type: &ServiceTypeId) -> Option<&ServiceTypeInfo> {
201        self.service_types
202            .values()
203            .find(|info| service_type_matches(&info.service_type, service_type))
204    }
205
206    /// Convenience method to return a mutable reference to the [`ServiceTypeInfo`] of a service type.
207    pub fn get_service_type_mut(&mut self, service_type: &ServiceTypeId) -> Option<&mut ServiceTypeInfo> {
208        self.service_types
209            .values_mut()
210            .find(|info| service_type_matches(&info.service_type, service_type))
211    }
212
213    /// Convenience method to return a reference to an [`Account`] with a given [`ChainAddress`].
214    pub fn get_account(&self, chain_key: &ChainAddress) -> Option<&Account> {
215        self.accounts
216            .values()
217            .find(|acc| acc.chain_key == hex::encode(chain_key))
218    }
219
220    /// Convenience method to return a mutable reference to an [`Account`] with a given [`ChainAddress`].
221    pub fn get_account_mut(&mut self, chain_key: &ChainAddress) -> Option<&mut Account> {
222        self.accounts
223            .values_mut()
224            .find(|acc| acc.chain_key == hex::encode(chain_key))
225    }
226
227    /// Convenience method to return a reference to a [`Channel`] with a given [` ChannelId `].
228    pub fn get_channel_by_id(&self, channel_id: &ChannelId) -> Option<&Channel> {
229        self.channels.get(&hex::encode(channel_id))
230    }
231
232    /// Convenience method to return a mutable reference to a [`Channel`] with a given [` ChannelId `].
233    pub fn get_channel_by_id_mut(&mut self, channel_id: &ChannelId) -> Option<&mut Channel> {
234        self.channels.get_mut(&hex::encode(channel_id))
235    }
236
237    /// Convenience method to return a reference to Safe balance corresponding to the given [`ChainAddress`] of the
238    /// [`Account`].
239    pub fn get_account_safe_token_balance(&self, chain_key: &ChainAddress) -> Option<&HoprBalance> {
240        let account = self.get_account(chain_key)?;
241        self.token_balances.get(account.safe_address.as_ref()?)
242    }
243
244    /// Convenience method to return a mutable reference to Safe balance corresponding to the given [`ChainAddress`] of
245    /// the [`Account`].
246    pub fn get_account_safe_token_balance_mut(&mut self, chain_key: &ChainAddress) -> Option<&mut HoprBalance> {
247        let account = self.get_account(chain_key).and_then(|a| a.safe_address.clone())?;
248        self.token_balances.get_mut(&account)
249    }
250
251    /// Convenience method to return a reference to the Safe's native balance corresponding to the given
252    /// [`ChainAddress`] of the [`Account`].
253    pub fn get_account_safe_native_balance(&self, chain_key: &ChainAddress) -> Option<&NativeBalance> {
254        let account = self.get_account(chain_key)?;
255        self.native_balances.get(account.safe_address.as_ref()?)
256    }
257
258    /// Convenience method to return a mutable reference to the Safe's native balance corresponding to the given
259    /// [`ChainAddress`] of the [`Account`].
260    pub fn get_account_safe_native_balance_mut(&mut self, chain_key: &ChainAddress) -> Option<&mut NativeBalance> {
261        let account = self.get_account(chain_key).and_then(|a| a.safe_address.clone())?;
262        self.native_balances.get_mut(&account)
263    }
264
265    /// Convenience method to return a reference to Safe allowance corresponding to the given [`ChainAddress`] of the
266    /// [`Account`].
267    pub fn get_account_safe_allowance(&self, chain_key: &ChainAddress) -> Option<&SafeHoprAllowance> {
268        let account = self.get_account(chain_key)?;
269        self.safe_allowances.get(account.safe_address.as_ref()?)
270    }
271
272    /// Convenience method to return a mutable reference to Safe allowance corresponding to the given [`ChainAddress`]
273    /// of the [`Account`].
274    pub fn get_account_safe_allowance_mut(&mut self, chain_key: &ChainAddress) -> Option<&mut SafeHoprAllowance> {
275        let account = self.get_account(chain_key).and_then(|a| a.safe_address.clone())?;
276        self.safe_allowances.get_mut(&account)
277    }
278
279    /// Gets [`RedeemedStats`] for the given Safe address.
280    pub fn get_safe_redeem_stats(&self, chain_address: &ChainAddress) -> Option<&RedeemedStats> {
281        self.safe_redeem_stats.get(&hex::encode(chain_address))
282    }
283
284    /// Gets [`RedeemedStats`] for the given Safe address by mutable reference.
285    pub fn get_safe_redeem_stats_mut(&mut self, chain_address: &ChainAddress) -> Option<&mut RedeemedStats> {
286        self.safe_redeem_stats.get_mut(&hex::encode(chain_address))
287    }
288
289    /// Convenience method to return references to [`Safe`]s with the given owner's [`ChainAddress`].
290    pub fn get_safe_by_owner(&self, owner: &ChainAddress) -> Vec<&Safe> {
291        let owner_hex = hex::encode(owner);
292        self.deployed_safes
293            .values()
294            .filter(|safe| Self::safe_matches_owner(safe, &owner_hex))
295            .collect()
296    }
297
298    /// Convenience method to return mutable references to [`Safe`]s with the given owner's [`ChainAddress`].
299    pub fn get_safe_by_owner_mut(&mut self, owner: &ChainAddress) -> Vec<&mut Safe> {
300        let owner_hex = hex::encode(owner);
301        self.deployed_safes
302            .values_mut()
303            .filter(|safe| Self::safe_matches_owner(safe, &owner_hex))
304            .collect()
305    }
306}
307
308/// Applies signed-transaction effects to a [`BlokliTestState`].
309///
310/// Implement this trait when a test needs transaction submission methods to modify the in-memory state. The mutator is
311/// called synchronously while the test client holds the state write lock. If the mutator returns an error, the state is
312/// reverted to its previous value and the simulated transaction reports a failure according to the submission mode.
313pub trait BlokliTestStateMutator {
314    /// Updates the state given the signed transaction.
315    ///
316    /// [`BlokliTestClient`] makes several consistency checks on the updates.
317    /// For example, all mutations that remove anything from the state are not allowed.
318    ///
319    /// For arbitrary state updates via the client, see [`BlokliTestClient::hidden_state_update`].
320    fn update_state(&self, signed_tx: &[u8], state: &mut BlokliTestState) -> Result<()>;
321}
322
323/// No-op state mutator.
324///
325/// Useful for tests that only query a fixed [`BlokliTestState`] or that mutate state manually with
326/// [`BlokliTestClient::hidden_state_update`].
327#[derive(Clone, Debug, Default)]
328pub struct NopStateMutator;
329
330impl BlokliTestStateMutator for NopStateMutator {
331    fn update_state(&self, _: &[u8], _: &mut BlokliTestState) -> Result<()> {
332        Ok(())
333    }
334}
335
336impl<F: Fn(&[u8], &mut BlokliTestState) -> Result<()>> BlokliTestStateMutator for F {
337    fn update_state(&self, signed_tx: &[u8], state: &mut BlokliTestState) -> Result<()> {
338        self(signed_tx, state)
339    }
340}
341
342type AccountEvents = (
343    async_broadcast::Sender<Account>,
344    async_broadcast::InactiveReceiver<Account>,
345);
346
347type GraphEvents = (
348    async_broadcast::Sender<(Account, Channel, Account)>,
349    async_broadcast::InactiveReceiver<(Account, Channel, Account)>,
350);
351
352type TicketParamEvents = (
353    async_broadcast::Sender<TicketParameters>,
354    async_broadcast::InactiveReceiver<TicketParameters>,
355);
356
357type SafeDeployEvents = (async_broadcast::Sender<Safe>, async_broadcast::InactiveReceiver<Safe>);
358
359type ServiceEvents = (
360    async_broadcast::Sender<ServiceUpdate>,
361    async_broadcast::InactiveReceiver<ServiceUpdate>,
362);
363
364type ServiceTypeEvents = (
365    async_broadcast::Sender<ServiceTypeUpdate>,
366    async_broadcast::InactiveReceiver<ServiceTypeUpdate>,
367);
368
369type ServiceRegistryConfigEvents = (
370    async_broadcast::Sender<ServiceRegistryConfig>,
371    async_broadcast::InactiveReceiver<ServiceRegistryConfig>,
372);
373
374/// Snapshot of the [`BlokliTestState`] inside a [`BlokliTestClient`].
375///
376/// Snapshots are cheap handles containing a cloned state view. Call [`refresh`](BlokliTestStateSnapshot::refresh) to
377/// replace the stored view with the latest shared state.
378#[derive(Clone)]
379pub struct BlokliTestStateSnapshot {
380    state: Arc<parking_lot::RwLock<BlokliTestState>>,
381    snapshot: BlokliTestState,
382}
383
384impl BlokliTestStateSnapshot {
385    /// Refreshes the snapshot by fetching it from the [`BlokliTestClient`].
386    pub fn refresh(mut self) -> Self {
387        {
388            let state = self.state.read();
389            self.snapshot = state.clone();
390        }
391        self
392    }
393}
394
395impl AsRef<BlokliTestState> for BlokliTestStateSnapshot {
396    fn as_ref(&self) -> &BlokliTestState {
397        &self.snapshot
398    }
399}
400
401impl std::ops::Deref for BlokliTestStateSnapshot {
402    type Target = BlokliTestState;
403
404    fn deref(&self) -> &Self::Target {
405        &self.snapshot
406    }
407}
408
409/// In-memory Blokli client for tests.
410///
411/// `BlokliTestClient` implements [`BlokliQueryClient`], [`BlokliSubscriptionClient`], and
412/// [`BlokliTransactionClient`] against a shared [`BlokliTestState`]. Clones share the same state and broadcast
413/// channels, which makes it possible to submit simulated transactions from one handle and observe subscription updates
414/// from another.
415///
416/// Transactions submitted through the client call the configured [`BlokliTestStateMutator`]. Mutations that remove
417/// accounts, channels, balances, allowances, or active transactions are rejected to avoid producing inconsistent test
418/// state. For direct fixture edits that should not emit subscription events, use
419/// [`hidden_state_update`](BlokliTestClient::hidden_state_update).
420///
421/// This type is exported only with the `testing` feature.
422#[derive(Clone)]
423pub struct BlokliTestClient<M> {
424    state: Arc<parking_lot::RwLock<BlokliTestState>>,
425    mutator: M,
426    accounts_channel: AccountEvents,
427    channels_channel: GraphEvents,
428    ticket_channel: TicketParamEvents,
429    safe_deployed_channel: SafeDeployEvents,
430    services_channel: ServiceEvents,
431    service_types_channel: ServiceTypeEvents,
432    service_registry_config_channel: ServiceRegistryConfigEvents,
433    tx_simulation_delay: Duration,
434    use_internal_txs: bool,
435}
436
437fn channel_matches(channel: &Channel, selector: &ChannelSelector, accounts: &IndexMap<u32, Account>) -> bool {
438    let filter = match selector.filter {
439        Some(ChannelFilter::ChannelId(id)) => channel.concrete_channel_id == hex::encode(id),
440        Some(ChannelFilter::DestinationKeyId(dst_id)) => channel.destination as u32 == dst_id,
441        Some(ChannelFilter::SourceKeyId(src_id)) => channel.source as u32 == src_id,
442        Some(ChannelFilter::SourceAndDestinationKeyIds(src_id, dst_id)) => {
443            channel.source as u32 == src_id && channel.destination as u32 == dst_id
444        }
445        None => true,
446    };
447    let safe_ok = selector.safe_address.is_none_or(|safe| {
448        accounts
449            .get(&(channel.source as u32))
450            .and_then(|acc| acc.safe_address.as_ref())
451            .is_some_and(|acc_safe| *acc_safe == hex::encode(safe))
452    });
453    filter && safe_ok && selector.status.is_none_or(|status| channel.status == status)
454}
455
456fn account_matches(account: &Account, selector: &AccountSelector) -> bool {
457    match selector {
458        AccountSelector::Address(address) => account.chain_key == hex::encode(address),
459        AccountSelector::KeyId(id) => account.keyid as u32 == *id,
460        AccountSelector::PacketKey(packet_key) => account.packet_key == hex::encode(packet_key),
461        AccountSelector::Any => true,
462    }
463}
464
465/// Compares a hexadecimal string from a fixture with raw bytes, tolerating a `0x` prefix and either case.
466fn hex_matches(value: &str, expected: &[u8]) -> bool {
467    value
468        .trim_start_matches("0x")
469        .eq_ignore_ascii_case(&hex::encode(expected))
470}
471
472/// Matches a service type as Blokli renders it against a raw [`ServiceTypeId`].
473///
474/// Both renderings are accepted, so a fixture can spell a type either as its ASCII name or as hex.
475fn service_type_matches(rendered: &str, wanted: &ServiceTypeId) -> bool {
476    hex_matches(rendered, wanted) || service_type_name(wanted).is_some_and(|name| rendered == name)
477}
478
479fn service_matches(service_type: &str, node: &str, selector: &ServiceSelector) -> bool {
480    match selector {
481        ServiceSelector::ServiceType(wanted) => service_type_matches(service_type, wanted),
482        ServiceSelector::Node(wanted) => hex_matches(node, wanted),
483        ServiceSelector::ServiceTypeAndNode {
484            service_type: wanted_type,
485            node: wanted_node,
486        } => service_type_matches(service_type, wanted_type) && hex_matches(node, wanted_node),
487        ServiceSelector::Any => true,
488    }
489}
490
491fn broadcast_or_log<T: Clone>(sender: &async_broadcast::Sender<T>, value: T, description: &str) {
492    match sender.try_broadcast(value) {
493        Err(TrySendError::Full(_)) => {
494            tracing::error!("failed to broadcast {description} - channel is full");
495        }
496        Err(TrySendError::Closed(_)) => {
497            tracing::error!("failed to broadcast {description} - channel is closed");
498        }
499        _ => {}
500    }
501}
502
503impl<M: BlokliTestStateMutator> BlokliTestClient<M> {
504    /// Constructs a new client that owns the given [`initial_state`](BlokliTestState).
505    ///
506    /// After construction, the only way to mutate the state is when the client calls the given
507    /// [`mutator`](BlokliTestStateMutator) based on a [submitted](BlokliTransactionClient) transaction.
508    pub fn new(initial_state: BlokliTestState, mutator: M) -> Self {
509        let (mut accounts_tx, accounts_rx) = async_broadcast::broadcast(1024);
510        accounts_tx.set_await_active(false);
511        accounts_tx.set_overflow(false);
512
513        let (mut channels_tx, channels_rx) = async_broadcast::broadcast(1024);
514        channels_tx.set_await_active(false);
515        channels_tx.set_overflow(false);
516
517        let (mut tickets_tx, tickets_rx) = async_broadcast::broadcast(1024);
518        tickets_tx.set_await_active(false);
519        tickets_tx.set_overflow(false);
520
521        let (mut safes_tx, safes_rx) = async_broadcast::broadcast(1024);
522        safes_tx.set_await_active(false);
523        safes_tx.set_overflow(false);
524
525        let (mut services_tx, services_rx) = async_broadcast::broadcast(1024);
526        services_tx.set_await_active(false);
527        services_tx.set_overflow(false);
528
529        let (mut service_types_tx, service_types_rx) = async_broadcast::broadcast(1024);
530        service_types_tx.set_await_active(false);
531        service_types_tx.set_overflow(false);
532
533        let (mut service_registry_config_tx, service_registry_config_rx) = async_broadcast::broadcast(1024);
534        service_registry_config_tx.set_await_active(false);
535        service_registry_config_tx.set_overflow(false);
536
537        Self {
538            state: Arc::new(parking_lot::RwLock::new(initial_state)),
539            mutator,
540            accounts_channel: (accounts_tx, accounts_rx.deactivate()),
541            channels_channel: (channels_tx, channels_rx.deactivate()),
542            ticket_channel: (tickets_tx, tickets_rx.deactivate()),
543            safe_deployed_channel: (safes_tx, safes_rx.deactivate()),
544            services_channel: (services_tx, services_rx.deactivate()),
545            service_types_channel: (service_types_tx, service_types_rx.deactivate()),
546            service_registry_config_channel: (service_registry_config_tx, service_registry_config_rx.deactivate()),
547            tx_simulation_delay: Duration::from_secs(1),
548            use_internal_txs: false,
549        }
550    }
551
552    /// Replaces the transaction mutator.
553    ///
554    /// The returned client keeps the same shared state and subscription channels.
555    #[must_use]
556    pub fn with_mutator(mut self, mutator: M) -> Self {
557        self.mutator = mutator;
558        self
559    }
560
561    /// Enables or disables internal safe transaction simulation.
562    ///
563    /// When enabled, a mutator error wrapped in [`InternalTxError`](crate::errors::InternalTxError) produces a
564    /// confirmed outer transaction with failed safe execution details. The default is disabled.
565    #[must_use]
566    pub fn with_use_internal_txs(mut self, use_internal_txs: bool) -> Self {
567        self.use_internal_txs = use_internal_txs;
568        self
569    }
570
571    /// Sets the delay before a simulated transaction is confirmed or emitted by tracking streams.
572    ///
573    /// The default is 1 second.
574    #[must_use]
575    pub fn with_tx_simulation_delay(mut self, tx_simulation_delay: Duration) -> Self {
576        self.tx_simulation_delay = tx_simulation_delay;
577        self
578    }
579
580    /// Returns the current snapshot of the internal state.
581    ///
582    /// The snapshot can be repeatedly [refreshed](BlokliTestStateSnapshot::refresh) to get the latest state.
583    pub fn snapshot(&self) -> BlokliTestStateSnapshot {
584        let state = self.state.read();
585        BlokliTestStateSnapshot {
586            state: self.state.clone(),
587            snapshot: state.clone(),
588        }
589    }
590
591    /// Performs an arbitrary state update without broadcasting subscription events.
592    ///
593    /// This is useful for arranging fixtures between assertions. Use transaction submission or
594    /// [`update_price_and_win_prob`](BlokliTestClient::update_price_and_win_prob) when tests need subscribers to
595    /// observe the change.
596    pub fn hidden_state_update(&self, update: impl FnOnce(&mut BlokliTestState)) {
597        let mut state = self.state.write();
598        update(&mut state);
599    }
600
601    /// Updates the ticket price and/or minimum ticket-winning probability.
602    ///
603    /// These changes update the shared state and broadcast a [`TicketParameters`] event to active subscribers when at
604    /// least one value changes.
605    pub fn update_price_and_win_prob(&self, new_price: Option<TokenValueString>, new_win_prob: Option<f64>) {
606        let mut updated = false;
607        let (new_price_param, new_win_prob_param) = {
608            let mut state = self.state.write();
609
610            let mut new_price_param = state.chain_info.ticket_price.clone();
611            if let Some(new_price) = new_price {
612                state.chain_info.ticket_price = new_price.clone();
613
614                new_price_param = new_price;
615                updated = true;
616            }
617
618            let mut new_win_prob_param = state.chain_info.min_ticket_winning_probability;
619            if let Some(new_win_prob) = new_win_prob {
620                state.chain_info.min_ticket_winning_probability = new_win_prob;
621
622                new_win_prob_param = new_win_prob;
623                updated = true;
624            }
625            (new_price_param, new_win_prob_param)
626        };
627
628        if updated
629            && let Err(error) = self.ticket_channel.0.try_broadcast(TicketParameters {
630                min_ticket_winning_probability: new_win_prob_param,
631                ticket_price: new_price_param,
632            })
633        {
634            tracing::error!(%error, "failed to broadcast ticket parameters update");
635        }
636    }
637
638    fn do_query_channels(&self, selector: ChannelSelector) -> Result<Vec<Channel>> {
639        let state = self.state.read();
640        Ok(state
641            .channels
642            .values()
643            .filter(|c| channel_matches(c, &selector, &state.accounts))
644            .cloned()
645            .collect())
646    }
647
648    fn do_query_accounts(&self, selector: AccountSelector) -> Result<Vec<Account>> {
649        Ok(self
650            .state
651            .read()
652            .accounts
653            .values()
654            .filter(|a| account_matches(a, &selector))
655            .cloned()
656            .collect())
657    }
658
659    fn do_query_services(&self, selector: ServiceSelector) -> Result<Vec<ServiceEntry>> {
660        Ok(self
661            .state
662            .read()
663            .services
664            .values()
665            .filter(|entry| service_matches(&entry.service_type, &entry.node, &selector))
666            .cloned()
667            .collect())
668    }
669}
670
671#[async_trait::async_trait]
672impl<M: BlokliTestStateMutator + Send + Sync> BlokliQueryClient for BlokliTestClient<M> {
673    #[cfg(feature = "curvy")]
674    async fn query_curvy_pending_notes(
675        &self,
676        _from_block: Option<u64>,
677        _after: Option<CurvyEventCursor>,
678        _first: u32,
679    ) -> Result<CurvyPendingNotes> {
680        Ok(CurvyPendingNotes { notes: Vec::new() })
681    }
682
683    #[cfg(feature = "curvy")]
684    async fn query_curvy_committed_notes(
685        &self,
686        _from_block: Option<u64>,
687        _after: Option<CurvyEventCursor>,
688        _first: u32,
689    ) -> Result<CurvyCommittedNotes> {
690        Ok(CurvyCommittedNotes { notes: Vec::new() })
691    }
692
693    #[cfg(feature = "curvy")]
694    async fn query_curvy_committed_nullifiers(
695        &self,
696        _from_block: Option<u64>,
697        _after: Option<CurvyEventCursor>,
698        _first: u32,
699    ) -> Result<CurvyCommittedNullifiers> {
700        Ok(CurvyCommittedNullifiers { nullifiers: Vec::new() })
701    }
702
703    #[cfg(feature = "curvy")]
704    async fn query_curvy_sync_checkpoint(&self, _block_hash: Option<String>) -> Result<CurvySyncCheckpoint> {
705        Err(ErrorKind::NoData.into())
706    }
707
708    #[cfg(feature = "curvy")]
709    async fn query_curvy_sync_notes(
710        &self,
711        _checkpoint: String,
712        _from_index: Option<u64>,
713        _first: u32,
714    ) -> Result<CurvySyncNotePage> {
715        Err(ErrorKind::NoData.into())
716    }
717
718    #[cfg(feature = "curvy")]
719    async fn query_curvy_sync_nullifiers(
720        &self,
721        _checkpoint: String,
722        _from_index: Option<u64>,
723        _first: u32,
724    ) -> Result<CurvySyncNullifierPage> {
725        Err(ErrorKind::NoData.into())
726    }
727
728    #[cfg(feature = "curvy")]
729    async fn query_curvy_shard_roots(
730        &self,
731        _checkpoint: String,
732        _from_index: Option<u64>,
733        _first: u32,
734    ) -> Result<CurvyShardRootPage> {
735        Err(ErrorKind::NoData.into())
736    }
737
738    #[cfg(feature = "curvy")]
739    async fn query_curvy_aggregator_state(&self) -> Result<CurvyAggregatorState> {
740        Err(ErrorKind::NoData.into())
741    }
742
743    #[cfg(feature = "curvy")]
744    async fn query_curvy_note_status(&self, _note_id: String) -> Result<CurvyNoteStatus> {
745        Err(ErrorKind::NoData.into())
746    }
747
748    #[cfg(feature = "curvy")]
749    async fn query_curvy_valid_notes_root(&self, _root: String) -> Result<bool> {
750        Err(ErrorKind::NoData.into())
751    }
752
753    #[cfg(feature = "curvy")]
754    async fn query_curvy_nullifier_spent(&self, _nullifier: String) -> Result<bool> {
755        Err(ErrorKind::NoData.into())
756    }
757
758    #[cfg(feature = "curvy")]
759    async fn query_curvy_vault_fees(&self) -> Result<CurvyVaultFees> {
760        Err(ErrorKind::NoData.into())
761    }
762
763    #[cfg(feature = "curvy")]
764    async fn query_curvy_aggregator_fees(&self) -> Result<CurvyAggregatorFees> {
765        Err(ErrorKind::NoData.into())
766    }
767
768    #[cfg(feature = "curvy")]
769    async fn query_curvy_vault_token_count(&self) -> Result<CurvyVaultTokenCount> {
770        Err(ErrorKind::NoData.into())
771    }
772
773    #[cfg(feature = "curvy")]
774    async fn query_curvy_vault_token(&self, _token_id: String) -> Result<CurvyVaultToken> {
775        Err(ErrorKind::NoData.into())
776    }
777
778    #[cfg(feature = "curvy")]
779    async fn query_curvy_entry_portal_address(&self, _owner_hash: String, _recovery: String) -> Result<String> {
780        Err(ErrorKind::NoData.into())
781    }
782
783    #[cfg(feature = "curvy")]
784    async fn query_curvy_exit_portal_address(
785        &self,
786        _exit_address: String,
787        _exit_chain_id: String,
788        _recovery: String,
789    ) -> Result<String> {
790        Err(ErrorKind::NoData.into())
791    }
792
793    #[cfg(feature = "curvy")]
794    async fn query_curvy_portal_registered(&self, _portal_address: String) -> Result<bool> {
795        Err(ErrorKind::NoData.into())
796    }
797
798    async fn count_accounts(&self, selector: AccountSelector) -> Result<u32> {
799        Ok(match selector {
800            AccountSelector::Any => self.state.read().accounts.len() as u32,
801            selector => self.query_accounts(selector).await?.len() as u32,
802        })
803    }
804
805    async fn query_accounts(&self, selector: AccountSelector) -> Result<Vec<Account>> {
806        self.do_query_accounts(selector)
807    }
808
809    async fn query_native_balance(&self, address: &ChainAddress) -> Result<NativeBalance> {
810        let address = hex::encode(address);
811        self.state
812            .read()
813            .native_balances
814            .get(&address)
815            .cloned()
816            .ok_or_else(|| ErrorKind::NoData.into())
817    }
818
819    async fn query_token_balance(&self, address: &ChainAddress, _token: Token) -> Result<HoprBalance> {
820        let address = hex::encode(address);
821        self.state
822            .read()
823            .token_balances
824            .get(&address)
825            .cloned()
826            .ok_or_else(|| ErrorKind::NoData.into())
827    }
828
829    async fn query_transaction_count(&self, address: &ChainAddress) -> Result<u64> {
830        let address = hex::encode(address);
831        let state = self.state.upgradable_read();
832        if let Some(value) = state.tx_counts.get(&address) {
833            return Ok(*value);
834        }
835
836        let mut state = parking_lot::RwLockUpgradableReadGuard::upgrade(state);
837        Ok(*state.tx_counts.entry(address).or_default())
838    }
839
840    async fn query_safe_allowance(&self, address: &ChainAddress) -> Result<SafeHoprAllowance> {
841        let address = hex::encode(address);
842        self.state
843            .read()
844            .safe_allowances
845            .get(&address)
846            .cloned()
847            .ok_or_else(|| ErrorKind::NoData.into())
848    }
849
850    async fn query_redeemed_stats(&self, selector: RedeemedStatsSelector) -> Result<RedeemedStats> {
851        let state = self.state.upgradable_read();
852
853        let maybe_safe = match selector {
854            RedeemedStatsSelector::SafeAddress(addr) => Some(addr),
855            RedeemedStatsSelector::SafeAndNodeAddress { safe_address, .. } => Some(safe_address),
856            RedeemedStatsSelector::NodeAddress(_) => None,
857        };
858
859        if let Some(safe_address) = maybe_safe {
860            let safe_address_hex = hex::encode(safe_address);
861            if !state.deployed_safes.contains_key(&safe_address_hex) {
862                return Err(ErrorKind::NoData.into());
863            }
864
865            if let Some(v) = state.safe_redeem_stats.get(&safe_address_hex) {
866                Ok(v.clone())
867            } else {
868                let mut state = parking_lot::RwLockUpgradableReadGuard::upgrade(state);
869                let stats = RedeemedStats {
870                    __typename: "RedeemedStats".to_string(),
871                    redeemed_amount: TokenValueString("0 wxHOPR".into()),
872                    redemption_count: Uint64("0".into()),
873                    rejected_amount: TokenValueString("0 wxHOPR".into()),
874                    rejection_count: Uint64("0".into()),
875                };
876                state.safe_redeem_stats.insert(safe_address_hex, stats.clone());
877                Ok(stats)
878            }
879        } else {
880            Err(ErrorKind::NoData.into())
881        }
882    }
883
884    async fn query_safe(&self, selector: SafeSelector) -> Result<Vec<Safe>> {
885        let state = self.state.read();
886        match selector {
887            SafeSelector::SafeAddress(addr) => Ok(state
888                .deployed_safes
889                .get(&hex::encode(addr))
890                .cloned()
891                .into_iter()
892                .collect()),
893            SafeSelector::Owner(owner_address) | SafeSelector::ChainKey(owner_address) => Ok(state
894                .deployed_safes
895                .values()
896                .filter(|s| BlokliTestState::safe_matches_owner(s, &hex::encode(owner_address)))
897                .cloned()
898                .collect()),
899            SafeSelector::RegisteredNode(node_address) => Ok(state
900                .deployed_safes
901                .values()
902                .filter(|s| s.registered_nodes.contains(&hex::encode(node_address)))
903                .cloned()
904                .collect()),
905        }
906    }
907
908    async fn query_module_address_prediction(&self, input: ModulePredictionInput) -> Result<ChainAddress> {
909        let hash = Hash::create(&[
910            input.nonce.to_be_bytes().as_ref(),
911            input.owner.as_ref(),
912            input.safe_address.as_ref(),
913        ]);
914
915        hash.as_ref()[0..20].try_into().map_err(|_| ErrorKind::NoData.into())
916    }
917
918    async fn count_channels(&self, selector: ChannelSelector) -> Result<u32> {
919        Ok(if selector.matches_all() {
920            self.state.read().channels.len() as u32
921        } else {
922            self.query_channels(selector).await?.channels.len() as u32
923        })
924    }
925
926    async fn query_channel_stats(&self, selector: ChannelSelector) -> Result<ChannelStats> {
927        let channels = self.do_query_channels(selector)?;
928        let count = i32::try_from(channels.len()).map_err(|_| ErrorKind::ParseError)?;
929        let mut total = PrimitiveHoprBalance::zero();
930        for ch in &channels {
931            let bal: PrimitiveHoprBalance = ch.balance.0.parse().map_err(|_| ErrorKind::ParseError)?;
932            total += bal;
933        }
934        Ok(ChannelStats {
935            count,
936            balance: TokenValueString(total.to_string()),
937        })
938    }
939
940    async fn query_channels(&self, selector: ChannelSelector) -> Result<ChannelsList> {
941        let channels = self.do_query_channels(selector)?;
942        Ok(ChannelsList {
943            __typename: "ChannelsList".to_string(),
944            channels,
945        })
946    }
947
948    async fn query_safes_balance(&self, owner_address: Option<ChainAddress>) -> Result<SafesBalance> {
949        let state = self.state.read();
950        let matching_safes: Vec<&Safe> = if let Some(owner) = owner_address {
951            let owner_hex = hex::encode(owner);
952            state
953                .deployed_safes
954                .values()
955                .filter(|s| BlokliTestState::safe_matches_owner(s, &owner_hex))
956                .collect()
957        } else {
958            state.deployed_safes.values().collect()
959        };
960
961        let count = i32::try_from(matching_safes.len()).map_err(|_| ErrorKind::ParseError)?;
962        let mut total = PrimitiveHoprBalance::zero();
963        for safe in &matching_safes {
964            if let Some(hopr_balance) = state.token_balances.get(&safe.address) {
965                let bal: PrimitiveHoprBalance = hopr_balance.balance.0.parse().map_err(|_| ErrorKind::ParseError)?;
966                total += bal;
967            }
968        }
969
970        Ok(SafesBalance {
971            count,
972            balance: TokenValueString(total.to_string()),
973        })
974    }
975
976    async fn count_services(&self, selector: ServiceSelector) -> Result<u32> {
977        Ok(match selector {
978            ServiceSelector::Any => self.state.read().services.len() as u32,
979            selector => self.do_query_services(selector)?.len() as u32,
980        })
981    }
982
983    async fn query_services(&self, selector: ServiceSelector) -> Result<Vec<ServiceEntry>> {
984        self.do_query_services(selector)
985    }
986
987    async fn query_live_services(&self, selector: ServiceSelector) -> Result<Vec<ServiceEntry>> {
988        let entries = self.query_services(selector).await?;
989        let state = self.state.read();
990        Ok(entries
991            .into_iter()
992            .filter(|entry| {
993                state
994                    .deployed_safes
995                    .values()
996                    .any(|safe| safe.registered_nodes.iter().any(|node| node == &entry.node))
997            })
998            .collect())
999    }
1000
1001    async fn query_service_types(&self, service_type: Option<ServiceTypeId>) -> Result<Vec<ServiceTypeInfo>> {
1002        Ok(self
1003            .state
1004            .read()
1005            .service_types
1006            .values()
1007            .filter(|info| service_type.is_none_or(|wanted| service_type_matches(&info.service_type, &wanted)))
1008            .cloned()
1009            .collect())
1010    }
1011
1012    async fn query_service_registry_config(&self) -> Result<ServiceRegistryConfig> {
1013        Ok(self.state.read().service_registry_config.clone())
1014    }
1015
1016    async fn query_transaction_status(&self, tx_id: TxId) -> Result<Transaction> {
1017        self.state
1018            .read()
1019            .active_txs
1020            .get(&tx_id)
1021            .cloned()
1022            .ok_or_else(|| ErrorKind::NoData.into())
1023    }
1024
1025    async fn query_chain_info(&self) -> Result<ChainInfo> {
1026        Ok(self.state.read().chain_info.clone())
1027    }
1028
1029    async fn query_version(&self) -> Result<String> {
1030        Ok(self.state.read().version.clone())
1031    }
1032
1033    async fn query_health(&self) -> Result<String> {
1034        Ok(self.state.read().health.clone())
1035    }
1036
1037    async fn query_compatibility(&self) -> Result<Compatibility> {
1038        Ok(Compatibility {
1039            api_version: env!("CARGO_PKG_VERSION").to_string(),
1040            supported_client_versions: "*".to_string(),
1041            features: vec![],
1042        })
1043    }
1044}
1045
1046impl<M: BlokliTestStateMutator + Send + Sync> BlokliSubscriptionClient for BlokliTestClient<M> {
1047    fn subscribe_channels(&self, selector: ChannelSelector) -> Result<impl Stream<Item = Result<Channel>> + Send> {
1048        Ok(if selector.matches_all() {
1049            let channels = self.state.read().channels.clone();
1050            futures::stream::iter(channels.into_values())
1051                .map(Ok)
1052                .chain(
1053                    self.channels_channel
1054                        .1
1055                        .activate_cloned()
1056                        .map(|(_, channel, _)| Ok(channel)),
1057                )
1058                .boxed()
1059        } else {
1060            let accounts = self.state.read().accounts.clone();
1061            futures::stream::iter(self.do_query_channels(selector.clone())?)
1062                .map(Ok)
1063                .chain(
1064                    self.channels_channel
1065                        .1
1066                        .activate_cloned()
1067                        .filter(move |(_, c, _)| futures::future::ready(channel_matches(c, &selector, &accounts)))
1068                        .map(|(_, channel, _)| Ok(channel)),
1069                )
1070                .boxed()
1071        })
1072    }
1073
1074    fn subscribe_accounts(&self, selector: AccountSelector) -> Result<impl Stream<Item = Result<Account>> + Send> {
1075        Ok(match selector {
1076            AccountSelector::Any => {
1077                let accounts = self.state.read().accounts.clone();
1078                futures::stream::iter(accounts.into_values())
1079                    .map(Ok)
1080                    .chain(self.accounts_channel.1.activate_cloned().map(Ok))
1081                    .boxed()
1082            }
1083            selector => futures::stream::iter(self.do_query_accounts(selector.clone())?)
1084                .map(Ok)
1085                .chain(
1086                    self.accounts_channel
1087                        .1
1088                        .activate_cloned()
1089                        .filter(move |a| futures::future::ready(account_matches(a, &selector)))
1090                        .map(Ok),
1091                )
1092                .boxed(),
1093        })
1094    }
1095
1096    fn subscribe_graph(&self) -> Result<impl Stream<Item = Result<OpenedChannelsGraphEntry>> + Send> {
1097        let (accounts, channels) = {
1098            let state = self.state.read();
1099            (state.accounts.clone(), state.channels.clone())
1100        };
1101
1102        Ok(futures::stream::iter(channels.into_values().map(move |channel| {
1103            let source = accounts
1104                .get(&(channel.source as u32))
1105                .cloned()
1106                .ok_or_else(|| BlokliClientError::from(ErrorKind::NoData))?;
1107            let destination = accounts
1108                .get(&(channel.destination as u32))
1109                .cloned()
1110                .ok_or_else(|| BlokliClientError::from(ErrorKind::NoData))?;
1111
1112            Ok::<_, BlokliClientError>(OpenedChannelsGraphEntry {
1113                channel,
1114                destination,
1115                source,
1116            })
1117        }))
1118        .chain(
1119            self.channels_channel
1120                .1
1121                .activate_cloned()
1122                .map(|(source, channel, destination)| {
1123                    Ok(OpenedChannelsGraphEntry {
1124                        channel,
1125                        destination,
1126                        source,
1127                    })
1128                }),
1129        ))
1130    }
1131
1132    fn subscribe_ticket_params(&self) -> Result<impl Stream<Item = Result<TicketParameters>> + Send> {
1133        let info = self.state.read().chain_info.clone();
1134        Ok(futures::stream::once(futures::future::ready(TicketParameters {
1135            min_ticket_winning_probability: info.min_ticket_winning_probability,
1136            ticket_price: info.ticket_price,
1137        }))
1138        .chain(self.ticket_channel.1.activate_cloned())
1139        .map(Ok))
1140    }
1141
1142    fn subscribe_health(&self) -> Result<impl Stream<Item = Result<ReadinessState>> + Send> {
1143        Ok(futures::stream::once(futures::future::ready(Ok(ReadinessState::Ready))).boxed())
1144    }
1145
1146    fn subscribe_safe_deployments(&self) -> Result<impl Stream<Item = Result<Safe>> + Send> {
1147        let safes = self.state.read().deployed_safes.clone();
1148        Ok(futures::stream::iter(safes.into_values())
1149            .chain(self.safe_deployed_channel.1.activate_cloned())
1150            .map(Ok))
1151    }
1152
1153    /// Streams current matching entries followed by changes produced by simulated transactions.
1154    fn subscribe_services(
1155        &self,
1156        selector: ServiceSelector,
1157    ) -> Result<impl Stream<Item = Result<ServiceUpdate>> + Send> {
1158        let (initial, updates) = {
1159            let state = self.state.read();
1160            let initial = state
1161                .services
1162                .values()
1163                .filter(|entry| service_matches(&entry.service_type, &entry.node, &selector))
1164                .cloned()
1165                .map(|entry| ServiceUpdate {
1166                    kind: ServiceUpdateKind::Registered,
1167                    service_type: entry.service_type.clone(),
1168                    node: entry.node.clone(),
1169                    entry: Some(entry),
1170                })
1171                .collect::<Vec<_>>();
1172            (initial, self.services_channel.1.activate_cloned())
1173        };
1174        Ok(futures::stream::iter(initial)
1175            .chain(updates)
1176            .filter(move |update| {
1177                futures::future::ready(service_matches(&update.service_type, &update.node, &selector))
1178            })
1179            .map(Ok))
1180    }
1181
1182    /// Streams service type configuration changes produced by simulated transactions.
1183    ///
1184    /// The two registry-wide kinds, [`ServiceTypeUpdateKind::RegistrationFeeChanged`] and
1185    /// [`ServiceTypeUpdateKind::RegistryPointerChanged`], are never emitted: [`BlokliTestState`] models per-type
1186    /// configuration only.
1187    fn subscribe_service_types(
1188        &self,
1189        service_type: Option<ServiceTypeId>,
1190    ) -> Result<impl Stream<Item = Result<ServiceTypeUpdate>> + Send> {
1191        let (initial, updates) = {
1192            let state = self.state.read();
1193            let initial = state
1194                .service_types
1195                .values()
1196                .filter(|config| service_type.is_none_or(|wanted| service_type_matches(&config.service_type, &wanted)))
1197                .cloned()
1198                .map(|config| ServiceTypeUpdate {
1199                    kind: ServiceTypeUpdateKind::Registered,
1200                    service_type: Some(config.service_type.clone()),
1201                    config: Some(config),
1202                    registry_config: None,
1203                })
1204                .collect::<Vec<_>>();
1205            (initial, self.service_types_channel.1.activate_cloned())
1206        };
1207        Ok(futures::stream::iter(initial)
1208            .chain(updates)
1209            .filter(move |update| {
1210                futures::future::ready(service_type.is_none_or(|wanted| {
1211                    update
1212                        .service_type
1213                        .as_deref()
1214                        .is_some_and(|rendered| service_type_matches(rendered, &wanted))
1215                }))
1216            })
1217            .map(Ok))
1218    }
1219
1220    fn subscribe_service_registry_config(
1221        &self,
1222    ) -> Result<impl Stream<Item = Result<ServiceRegistryConfig>> + Send + 'static> {
1223        // Activate the receiver while holding the state read lock. Simulated transactions hold the
1224        // write lock through mutation and publication, so no update can fall between the snapshot
1225        // and live portions of this test stream.
1226        let (initial, updates) = {
1227            let state = self.state.read();
1228            (
1229                state.service_registry_config.clone(),
1230                self.service_registry_config_channel.1.activate_cloned(),
1231            )
1232        };
1233        Ok(futures::stream::once(futures::future::ready(initial))
1234            .chain(updates)
1235            .map(Ok))
1236    }
1237
1238    fn subscribe_track_transaction(
1239        &self,
1240        tx_id: TxId,
1241    ) -> Result<impl futures::Stream<Item = Result<types::Transaction>> + Send> {
1242        let tx = self
1243            .state
1244            .write()
1245            .active_txs
1246            .shift_remove(&tx_id)
1247            .ok_or_else(|| BlokliClientError::from(ErrorKind::NoData))?;
1248
1249        Ok(futures::stream::once(futures::future::ok(tx)).delay(Duration2::from(self.tx_simulation_delay)))
1250    }
1251
1252    fn subscribe_ticket_redeemed(
1253        &self,
1254        _selector: TicketSelector,
1255    ) -> Result<impl futures::Stream<Item = Result<RedeemTicketDetails>> + Send> {
1256        Ok(futures::stream::empty())
1257    }
1258
1259    #[cfg(feature = "curvy")]
1260    fn subscribe_curvy_pending_notes(
1261        &self,
1262        _from_block: Option<u64>,
1263    ) -> Result<impl futures::Stream<Item = Result<CurvyPendingNote>> + Send> {
1264        Ok(futures::stream::empty())
1265    }
1266
1267    #[cfg(feature = "curvy")]
1268    fn subscribe_curvy_committed_notes(
1269        &self,
1270        _from_block: Option<u64>,
1271    ) -> Result<impl futures::Stream<Item = Result<CurvyCommittedNote>> + Send> {
1272        Ok(futures::stream::empty())
1273    }
1274
1275    #[cfg(feature = "curvy")]
1276    fn subscribe_curvy_committed_nullifiers(
1277        &self,
1278        _from_block: Option<u64>,
1279    ) -> Result<impl futures::Stream<Item = Result<CurvyCommittedNullifier>> + Send> {
1280        Ok(futures::stream::empty())
1281    }
1282}
1283
1284/// Broadcast senders that [`simulate_tx_execution`] notifies once the mutator has been applied.
1285struct SubscriptionSenders<'a> {
1286    accounts: &'a async_broadcast::Sender<Account>,
1287    channels: &'a async_broadcast::Sender<(Account, Channel, Account)>,
1288    tickets: &'a async_broadcast::Sender<TicketParameters>,
1289    safes: &'a async_broadcast::Sender<Safe>,
1290    services: &'a async_broadcast::Sender<ServiceUpdate>,
1291    service_types: &'a async_broadcast::Sender<ServiceTypeUpdate>,
1292    service_registry_config: &'a async_broadcast::Sender<ServiceRegistryConfig>,
1293}
1294
1295impl<M: BlokliTestStateMutator> BlokliTestClient<M> {
1296    fn subscription_senders(&self) -> SubscriptionSenders<'_> {
1297        SubscriptionSenders {
1298            accounts: &self.accounts_channel.0,
1299            channels: &self.channels_channel.0,
1300            tickets: &self.ticket_channel.0,
1301            safes: &self.safe_deployed_channel.0,
1302            services: &self.services_channel.0,
1303            service_types: &self.service_types_channel.0,
1304            service_registry_config: &self.service_registry_config_channel.0,
1305        }
1306    }
1307}
1308
1309/// Broadcasts the registry entry changes between two states.
1310///
1311/// Removals are broadcast as [`ServiceUpdateKind::Deregistered`] rather than rejected, unlike the removals guarded
1312/// against in [`simulate_tx_execution`]: deregistration is a first-class registry operation.
1313fn broadcast_service_changes(
1314    old_state: &BlokliTestState,
1315    state: &BlokliTestState,
1316    services_channel: &async_broadcast::Sender<ServiceUpdate>,
1317) {
1318    for (key, old_entry) in &old_state.services {
1319        if !state.services.contains_key(key) {
1320            broadcast_or_log(
1321                services_channel,
1322                ServiceUpdate {
1323                    kind: ServiceUpdateKind::Deregistered,
1324                    service_type: old_entry.service_type.clone(),
1325                    node: old_entry.node.clone(),
1326                    entry: None,
1327                },
1328                "service deregistration",
1329            );
1330        }
1331    }
1332
1333    for (key, new_entry) in &state.services {
1334        let kind = match old_state.services.get(key) {
1335            None => ServiceUpdateKind::Registered,
1336            Some(old_entry) if old_entry != new_entry => ServiceUpdateKind::Updated,
1337            Some(_) => continue,
1338        };
1339
1340        broadcast_or_log(
1341            services_channel,
1342            ServiceUpdate {
1343                kind,
1344                service_type: new_entry.service_type.clone(),
1345                node: new_entry.node.clone(),
1346                entry: Some(new_entry.clone()),
1347            },
1348            "service entry change",
1349        );
1350    }
1351}
1352
1353/// Broadcasts the per-type configuration changes between two states.
1354///
1355/// One event is emitted per changed field, matching the on-chain events. The two registry-wide kinds are never
1356/// emitted, because [`BlokliTestState`] models per-type configuration only.
1357fn broadcast_service_type_changes(
1358    old_state: &BlokliTestState,
1359    state: &BlokliTestState,
1360    service_types_channel: &async_broadcast::Sender<ServiceTypeUpdate>,
1361) {
1362    for (key, new_type) in &state.service_types {
1363        let kinds: Vec<ServiceTypeUpdateKind> = match old_state.service_types.get(key) {
1364            None => vec![ServiceTypeUpdateKind::Registered],
1365            Some(old_type) => [
1366                (old_type.owner != new_type.owner).then_some(ServiceTypeUpdateKind::OwnerChanged),
1367                (old_type.requirement != new_type.requirement).then_some(ServiceTypeUpdateKind::RequirementChanged),
1368                (old_type.registration_burn != new_type.registration_burn)
1369                    .then_some(ServiceTypeUpdateKind::RegistrationBurnChanged),
1370                (old_type.update_burn != new_type.update_burn).then_some(ServiceTypeUpdateKind::UpdateBurnChanged),
1371            ]
1372            .into_iter()
1373            .flatten()
1374            .collect(),
1375        };
1376
1377        for kind in kinds {
1378            broadcast_or_log(
1379                service_types_channel,
1380                ServiceTypeUpdate {
1381                    kind,
1382                    service_type: Some(new_type.service_type.clone()),
1383                    config: Some(new_type.clone()),
1384                    registry_config: None,
1385                },
1386                "service type change",
1387            );
1388        }
1389    }
1390}
1391
1392fn simulate_tx_execution(
1393    signed_tx: &[u8],
1394    state: &mut BlokliTestState,
1395    mutator: &dyn BlokliTestStateMutator,
1396    senders: SubscriptionSenders<'_>,
1397) -> Result<()> {
1398    let old_state = state.clone();
1399    if let Err(error) = mutator.update_state(signed_tx, state) {
1400        *state = old_state;
1401        return Err(error);
1402    }
1403
1404    if old_state.accounts.len() > state.accounts.len() {
1405        *state = old_state;
1406        return Err(ErrorKind::MockClientError(anyhow::anyhow!("mutation cannot remove accounts")).into());
1407    }
1408
1409    if old_state.channels.len() > state.channels.len() {
1410        *state = old_state;
1411        return Err(ErrorKind::MockClientError(anyhow::anyhow!("mutation cannot remove channels")).into());
1412    }
1413
1414    if old_state.native_balances.len() > state.native_balances.len() {
1415        *state = old_state;
1416        return Err(ErrorKind::MockClientError(anyhow::anyhow!("mutation cannot remove native balances")).into());
1417    }
1418
1419    if old_state.token_balances.len() > state.token_balances.len() {
1420        *state = old_state;
1421        return Err(ErrorKind::MockClientError(anyhow::anyhow!("mutation cannot remove token balances")).into());
1422    }
1423
1424    if old_state.safe_allowances.len() > state.safe_allowances.len() {
1425        *state = old_state;
1426        return Err(ErrorKind::MockClientError(anyhow::anyhow!("mutation cannot remove safe allowances")).into());
1427    }
1428
1429    if old_state.active_txs.len() > state.active_txs.len() {
1430        *state = old_state;
1431        return Err(ErrorKind::MockClientError(anyhow::anyhow!("mutation cannot remove active txs")).into());
1432    }
1433
1434    // Service registry entries are deliberately absent from these guards, because deregistration removes an entry.
1435    // Service types are guarded, because abandoning a type clears its owner instead of removing the type.
1436    if old_state.service_types.len() > state.service_types.len() {
1437        *state = old_state;
1438        return Err(ErrorKind::MockClientError(anyhow::anyhow!("mutation cannot remove service types")).into());
1439    }
1440
1441    // Compare accounts and broadcast changes
1442    state
1443        .accounts
1444        .iter()
1445        .filter(|&(new_id, new_account)| {
1446            old_state.accounts.get(new_id).is_none_or(|old_account| {
1447                // Change is notified only if safe address or multi addresses changed
1448                old_account.safe_address != new_account.safe_address
1449                    || old_account.multi_addresses != new_account.multi_addresses
1450            })
1451        })
1452        .for_each(
1453            |(_, changed_account)| match senders.accounts.try_broadcast(changed_account.clone()) {
1454                Err(TrySendError::Full(_)) => {
1455                    tracing::error!("failed to broadcast account change - channel is full");
1456                }
1457                Err(TrySendError::Closed(_)) => {
1458                    tracing::error!("failed to broadcast account change - channel is closed");
1459                }
1460                _ => {}
1461            },
1462        );
1463
1464    // Compare channels and broadcast changes
1465    state
1466        .channels
1467        .iter()
1468        .filter(|&(new_id, new_channel)| {
1469            old_state
1470                .channels
1471                .get(new_id)
1472                .is_none_or(|old_channel| old_channel != new_channel)
1473        })
1474        .filter_map(|(_, changed_channel)| {
1475            let source = state.accounts.get(&(changed_channel.source as u32)).cloned();
1476            let destination = state.accounts.get(&(changed_channel.destination as u32)).cloned();
1477            source
1478                .zip(destination)
1479                .map(|(source, destination)| (source, changed_channel.clone(), destination))
1480        })
1481        .for_each(|(source, changed_channel, destination)| {
1482            match senders.channels.try_broadcast((source, changed_channel, destination)) {
1483                Err(TrySendError::Full(_)) => {
1484                    tracing::error!("failed to broadcast channel change - channel is full");
1485                }
1486                Err(TrySendError::Closed(_)) => {
1487                    tracing::error!("failed to broadcast channel change - channel is closed");
1488                }
1489                _ => {}
1490            }
1491        });
1492
1493    if state.chain_info.min_ticket_winning_probability != old_state.chain_info.min_ticket_winning_probability
1494        || state.chain_info.ticket_price != old_state.chain_info.ticket_price
1495    {
1496        match senders.tickets.try_broadcast(TicketParameters {
1497            min_ticket_winning_probability: state.chain_info.min_ticket_winning_probability,
1498            ticket_price: state.chain_info.ticket_price.clone(),
1499        }) {
1500            Err(TrySendError::Full(_)) => {
1501                tracing::error!("failed to broadcast ticket params change - channel is full");
1502            }
1503            Err(TrySendError::Closed(_)) => {
1504                tracing::error!("failed to broadcast ticket params change - channel is closed");
1505            }
1506            _ => {}
1507        }
1508    }
1509
1510    // Compare safes and broadcast changes
1511    state
1512        .deployed_safes
1513        .iter()
1514        .filter(|&(new_id, new_safe)| {
1515            old_state
1516                .deployed_safes
1517                .get(new_id)
1518                .is_none_or(|old_safe| old_safe != new_safe)
1519        })
1520        .for_each(
1521            |(_, changed_safe)| match senders.safes.try_broadcast(changed_safe.clone()) {
1522                Err(TrySendError::Full(_)) => {
1523                    tracing::error!("failed to broadcast safe change - channel is full");
1524                }
1525                Err(TrySendError::Closed(_)) => {
1526                    tracing::error!("failed to broadcast safe change - channel is closed");
1527                }
1528                _ => {}
1529            },
1530        );
1531
1532    broadcast_service_changes(&old_state, state, senders.services);
1533    broadcast_service_type_changes(&old_state, state, senders.service_types);
1534    if old_state.service_registry_config != state.service_registry_config {
1535        broadcast_or_log(
1536            senders.service_registry_config,
1537            state.service_registry_config.clone(),
1538            "service registry configuration change",
1539        );
1540    }
1541
1542    Ok(())
1543}
1544
1545#[async_trait::async_trait]
1546impl<M: BlokliTestStateMutator + Send + Sync> BlokliTransactionClient for BlokliTestClient<M> {
1547    async fn submit_transaction(&self, signed_tx: &[u8]) -> Result<TxReceipt> {
1548        let mut tx_receipt = [0u8; 32];
1549        rand::fill(&mut tx_receipt);
1550
1551        let mut state = self.state.write();
1552        if let Err(error) = simulate_tx_execution(signed_tx, &mut state, &self.mutator, self.subscription_senders()) {
1553            tracing::error!(%error, signed_tx_data = hex::encode(signed_tx), "failed to execute transaction, state reverted");
1554        } else {
1555            tracing::debug!("transaction execution succeeded");
1556        }
1557
1558        Ok(tx_receipt)
1559    }
1560
1561    async fn submit_and_track_transaction(&self, signed_tx: &[u8]) -> Result<TxId> {
1562        let tx_id = hex::encode(rand::random_iter::<u8>().take(16).collect::<Vec<_>>());
1563        let tx_hash = hex::encode(rand::random_iter::<u8>().take(32).collect::<Vec<_>>());
1564
1565        let mut state = self.state.write();
1566
1567        let mut internal_tx_failure_reason: Option<String> = None;
1568
1569        let status = simulate_tx_execution(
1570            signed_tx,
1571            &mut state,
1572            &self.mutator,
1573            self.subscription_senders(),
1574        )
1575        .map(|_| {
1576            tracing::debug!("transaction execution succeeded");
1577            TransactionStatus::Confirmed
1578        })
1579        .inspect_err(|e| if let ErrorKind::MockClientError(int_err) = e.kind() {
1580            internal_tx_failure_reason = int_err.downcast_ref::<InternalTxError>().map(|err| err.0.to_string());
1581        })
1582        .unwrap_or_else(|error| {
1583            tracing::error!(%error, signed_tx_data = hex::encode(signed_tx), "failed to execute transaction, state reverted");
1584            // Make the outer transaction confirmed if there was an internal transaction failure
1585            if self.use_internal_txs && internal_tx_failure_reason.is_some() {
1586                TransactionStatus::Confirmed
1587            } else {
1588                TransactionStatus::Reverted
1589            }
1590        });
1591
1592        state.active_txs.insert(
1593            tx_id.clone(),
1594            Transaction {
1595                id: tx_id.clone().into(),
1596                status,
1597                submitted_at: DateTime(chrono::DateTime::<chrono::Utc>::from(SystemTime::now()).to_rfc3339()),
1598                transaction_hash: Hex32(tx_hash.clone()),
1599                safe_execution: (self.use_internal_txs && status == TransactionStatus::Confirmed).then(|| {
1600                    SafeExecution {
1601                        success: internal_tx_failure_reason.is_none(),
1602                        safe_tx_hash: Some(Hex32(tx_hash)),
1603                        revert_reason: internal_tx_failure_reason,
1604                    }
1605                }),
1606            },
1607        );
1608
1609        Ok(tx_id)
1610    }
1611
1612    async fn submit_and_confirm_transaction(&self, signed_tx: &[u8], num_confirmations: usize) -> Result<TxReceipt> {
1613        futures_time::task::sleep((self.tx_simulation_delay * num_confirmations as u32).into()).await;
1614
1615        let mut tx_receipt = [0u8; 32];
1616        rand::fill(&mut tx_receipt);
1617
1618        let mut state = self.state.write();
1619        simulate_tx_execution(
1620            signed_tx,
1621            &mut state,
1622            &self.mutator,
1623            self.subscription_senders(),
1624        )
1625        .inspect_err(|error| {
1626            tracing::error!(%error, signed_tx_data = hex::encode(signed_tx), "failed to execute transaction, state reverted");
1627        })?;
1628
1629        tracing::debug!("transaction execution succeeded");
1630        Ok(tx_receipt)
1631    }
1632
1633    async fn track_transaction(&self, tx_id: TxId, client_timeout: Duration) -> Result<Transaction> {
1634        futures_time::task::sleep(self.tx_simulation_delay.min(client_timeout.div(2)).into()).await;
1635        let tx = self
1636            .state
1637            .write()
1638            .active_txs
1639            .shift_remove(&tx_id)
1640            .ok_or_else(|| BlokliClientError::from(ErrorKind::NoData))?;
1641
1642        match tx.status {
1643            TransactionStatus::Confirmed => Ok(tx),
1644            TransactionStatus::Timeout => Err(ErrorKind::TrackingError(TrackingErrorKind::Timeout).into()),
1645            TransactionStatus::SubmissionFailed => {
1646                Err(ErrorKind::TrackingError(TrackingErrorKind::SubmissionFailed).into())
1647            }
1648            TransactionStatus::ValidationFailed => {
1649                Err(ErrorKind::TrackingError(TrackingErrorKind::ValidationFailed).into())
1650            }
1651            TransactionStatus::Reverted => Err(ErrorKind::TrackingError(TrackingErrorKind::Reverted).into()),
1652            _ => Err(ErrorKind::MockClientError(anyhow::anyhow!("unexpected transaction status")).into()),
1653        }
1654    }
1655}
1656
1657#[cfg(test)]
1658mod tests {
1659    use std::collections::BTreeMap;
1660
1661    use futures::StreamExt;
1662
1663    use super::{
1664        BlokliQueryClient, BlokliSubscriptionClient, BlokliTestClient, BlokliTestState, BlokliTransactionClient,
1665        ChainAddress, NopStateMutator, Result, ServiceEntry, ServiceRegistryConfig, ServiceSelector, ServiceTypeInfo,
1666        ServiceTypeUpdateKind, ServiceUpdateKind, Uint64,
1667    };
1668
1669    /// `bytes32("gvpn:exit")`, the canonical id of the GnosisVPN exit-node service.
1670    const GVPN_EXIT: [u8; 32] = [
1671        0x67, 0x76, 0x70, 0x6e, 0x3a, 0x65, 0x78, 0x69, 0x74, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
1672        0, 0, 0, 0,
1673    ];
1674    const NODE: ChainAddress = [0x11; 20];
1675    const OTHER_NODE: ChainAddress = [0x22; 20];
1676
1677    fn entry(service_type: &str, node: &ChainAddress) -> ServiceEntry {
1678        ServiceEntry {
1679            service_type: service_type.to_string(),
1680            node: hex::encode(node),
1681            safe: hex::encode([0x33; 20]),
1682            metadata: "0xdeadbeef".to_string(),
1683            registered_at: Uint64("1700000000".into()),
1684            updated_at: Uint64("1700000000".into()),
1685        }
1686    }
1687
1688    fn service_type_info(service_type: &str, owner: Option<&str>) -> ServiceTypeInfo {
1689        ServiceTypeInfo {
1690            service_type: service_type.to_string(),
1691            owner: owner.map(str::to_string),
1692            requirement: None,
1693            registration_burn: "1 wxHOPR".to_string(),
1694            update_burn: "0 wxHOPR".to_string(),
1695        }
1696    }
1697
1698    fn state_with_entries() -> BlokliTestState {
1699        let mut state = BlokliTestState::default();
1700        state.services.insert(
1701            BlokliTestState::service_entry_key("gvpn:exit", &NODE),
1702            entry("gvpn:exit", &NODE),
1703        );
1704        state.services.insert(
1705            BlokliTestState::service_entry_key("gvpn:relay", &OTHER_NODE),
1706            entry("gvpn:relay", &OTHER_NODE),
1707        );
1708        state
1709    }
1710
1711    /// `hopr_types::chain::ContractAddresses` puts `#[serde(default)]` on none of its fields, so a key missing from
1712    /// this blob is a runtime failure on the first transaction of any consumer test, not a compile error.
1713    #[test]
1714    fn default_contract_addresses_include_the_service_registry() -> anyhow::Result<()> {
1715        let blob = BlokliTestState::default().chain_info.contract_addresses.0;
1716        let addresses: BTreeMap<String, String> = serde_json::from_str(&blob)?;
1717
1718        // Consumers deserialize this blob into `hopr_types::chain::ContractAddresses`, which
1719        // under `use-bindings` is the bindings struct and carries no `#[serde(default)]` on any
1720        // field. A missing key is therefore a runtime failure on the first transaction of every
1721        // test that builds a dynamic client without calling a `with_*_chain_info` builder - and
1722        // it cannot fail to compile, because this is a string literal. This crate deliberately
1723        // does not depend on `hopr-types/chain`, so the field list is spelled out here instead
1724        // of being derived; keep it in step with that struct.
1725        let required = [
1726            "announcements",
1727            "channels",
1728            "module_implementation",
1729            "node_safe_migration",
1730            "node_safe_registry",
1731            "node_stake_factory",
1732            "service_registry",
1733            "ticket_price_oracle",
1734            "token",
1735            "winning_probability_oracle",
1736            "xhopr_token",
1737        ];
1738
1739        let missing: Vec<&str> = required
1740            .into_iter()
1741            .filter(|key| !addresses.contains_key(*key))
1742            .collect();
1743
1744        assert_eq!(Vec::<&str>::new(), missing);
1745        insta::assert_yaml_snapshot!(addresses);
1746
1747        Ok(())
1748    }
1749
1750    #[tokio::test]
1751    async fn query_services_accepts_the_any_selector() -> anyhow::Result<()> {
1752        let client = BlokliTestClient::new(state_with_entries(), NopStateMutator);
1753
1754        assert_eq!(client.query_services(ServiceSelector::Any).await?.len(), 2);
1755        Ok(())
1756    }
1757
1758    #[tokio::test]
1759    async fn count_services_accepts_the_any_selector() -> anyhow::Result<()> {
1760        let client = BlokliTestClient::new(state_with_entries(), NopStateMutator);
1761
1762        assert_eq!(client.count_services(ServiceSelector::Any).await?, 2);
1763        Ok(())
1764    }
1765
1766    #[tokio::test]
1767    async fn query_services_matches_a_service_type_written_as_its_ascii_name() -> anyhow::Result<()> {
1768        let client = BlokliTestClient::new(state_with_entries(), NopStateMutator);
1769
1770        let entries = client.query_services(ServiceSelector::ServiceType(GVPN_EXIT)).await?;
1771
1772        insta::assert_yaml_snapshot!(entries);
1773        Ok(())
1774    }
1775
1776    #[tokio::test]
1777    async fn query_services_matches_a_service_type_written_as_hex() -> anyhow::Result<()> {
1778        let mut state = BlokliTestState::default();
1779        let hex_id = format!("0x{}", hex::encode(GVPN_EXIT));
1780        state.services.insert(
1781            BlokliTestState::service_entry_key(&hex_id, &NODE),
1782            entry(&hex_id, &NODE),
1783        );
1784        let client = BlokliTestClient::new(state, NopStateMutator);
1785
1786        let entries = client.query_services(ServiceSelector::ServiceType(GVPN_EXIT)).await?;
1787
1788        assert_eq!(entries.len(), 1);
1789        Ok(())
1790    }
1791
1792    #[tokio::test]
1793    async fn query_services_narrows_to_one_node() -> anyhow::Result<()> {
1794        let client = BlokliTestClient::new(state_with_entries(), NopStateMutator);
1795
1796        let entries = client.query_services(ServiceSelector::Node(OTHER_NODE)).await?;
1797
1798        insta::assert_yaml_snapshot!(entries);
1799        Ok(())
1800    }
1801
1802    #[tokio::test]
1803    async fn query_service_types_returns_every_type_when_unfiltered() -> anyhow::Result<()> {
1804        let mut state = BlokliTestState::default();
1805        state
1806            .service_types
1807            .insert("gvpn:exit".to_string(), service_type_info("gvpn:exit", Some("0x4444")));
1808        state
1809            .service_types
1810            .insert("gvpn:relay".to_string(), service_type_info("gvpn:relay", None));
1811        let client = BlokliTestClient::new(state, NopStateMutator);
1812
1813        let types = client.query_service_types(None).await?;
1814
1815        insta::assert_yaml_snapshot!(types);
1816        Ok(())
1817    }
1818
1819    #[tokio::test]
1820    async fn query_service_registry_config_returns_current_configuration() -> anyhow::Result<()> {
1821        let mut state = BlokliTestState::default();
1822        state.service_registry_config = ServiceRegistryConfig {
1823            type_registration_fee: "1000 wxHOPR".into(),
1824            node_safe_registry: "0x4444444444444444444444444444444444444444".into(),
1825        };
1826        let client = BlokliTestClient::new(state, NopStateMutator);
1827
1828        let config = client.query_service_registry_config().await?;
1829
1830        insta::assert_yaml_snapshot!(config);
1831        Ok(())
1832    }
1833
1834    #[tokio::test]
1835    async fn subscribe_services_reports_registration_update_and_deregistration() -> anyhow::Result<()> {
1836        let client = BlokliTestClient::new(
1837            BlokliTestState::default(),
1838            |signed_tx: &[u8], state: &mut BlokliTestState| {
1839                let key = BlokliTestState::service_entry_key("gvpn:exit", &NODE);
1840                match signed_tx {
1841                    [0] => {
1842                        state.services.insert(key, entry("gvpn:exit", &NODE));
1843                    }
1844                    [1] => {
1845                        let mut updated = entry("gvpn:exit", &NODE);
1846                        updated.metadata = "0xc0ffee".to_string();
1847                        updated.updated_at = Uint64("1700000100".into());
1848                        state.services.insert(key, updated);
1849                    }
1850                    _ => {
1851                        state.services.shift_remove(&key);
1852                    }
1853                }
1854                Result::Ok(())
1855            },
1856        );
1857
1858        let mut stream = client.subscribe_services(ServiceSelector::ServiceType(GVPN_EXIT))?;
1859        for step in [0u8, 1, 2] {
1860            client.submit_transaction(&[step]).await?;
1861        }
1862
1863        let updates = stream.by_ref().take(3).collect::<Vec<_>>().await;
1864        let updates = updates.into_iter().collect::<Result<Vec<_>>>()?;
1865
1866        insta::assert_yaml_snapshot!(updates);
1867        assert_eq!(
1868            updates.iter().map(|update| update.kind).collect::<Vec<_>>(),
1869            vec![
1870                ServiceUpdateKind::Registered,
1871                ServiceUpdateKind::Updated,
1872                ServiceUpdateKind::Deregistered
1873            ]
1874        );
1875        Ok(())
1876    }
1877
1878    #[tokio::test]
1879    async fn subscribe_service_registry_config_reports_snapshot_then_update() -> anyhow::Result<()> {
1880        let mut initial = BlokliTestState::default();
1881        initial.service_registry_config = ServiceRegistryConfig {
1882            type_registration_fee: "1 wxHOPR".into(),
1883            node_safe_registry: "0x1111111111111111111111111111111111111111".into(),
1884        };
1885        let client = BlokliTestClient::new(initial, |_: &[u8], state: &mut BlokliTestState| {
1886            state.service_registry_config = ServiceRegistryConfig {
1887                type_registration_fee: "2 wxHOPR".into(),
1888                node_safe_registry: "0x2222222222222222222222222222222222222222".into(),
1889            };
1890            Result::Ok(())
1891        });
1892
1893        let mut stream = client.subscribe_service_registry_config()?;
1894        let initial = stream
1895            .next()
1896            .await
1897            .ok_or_else(|| anyhow::anyhow!("missing snapshot"))??;
1898        client.submit_transaction(&[0]).await?;
1899        let updated = stream.next().await.ok_or_else(|| anyhow::anyhow!("missing update"))??;
1900
1901        insta::assert_yaml_snapshot!(vec![initial, updated]);
1902        Ok(())
1903    }
1904
1905    #[tokio::test]
1906    async fn subscribe_services_filters_out_other_nodes() -> anyhow::Result<()> {
1907        let client = BlokliTestClient::new(BlokliTestState::default(), |_: &[u8], state: &mut BlokliTestState| {
1908            state.services.insert(
1909                BlokliTestState::service_entry_key("gvpn:exit", &OTHER_NODE),
1910                entry("gvpn:exit", &OTHER_NODE),
1911            );
1912            state.services.insert(
1913                BlokliTestState::service_entry_key("gvpn:exit", &NODE),
1914                entry("gvpn:exit", &NODE),
1915            );
1916            Result::Ok(())
1917        });
1918
1919        let mut stream = client.subscribe_services(ServiceSelector::Node(NODE))?;
1920        client.submit_transaction(&[0]).await?;
1921
1922        let update = stream
1923            .next()
1924            .await
1925            .ok_or_else(|| anyhow::anyhow!("service subscription ended early"))??;
1926
1927        assert_eq!(update.node, hex::encode(NODE));
1928        Ok(())
1929    }
1930
1931    #[tokio::test]
1932    async fn subscribe_service_types_reports_one_event_per_changed_field() -> anyhow::Result<()> {
1933        let mut initial = BlokliTestState::default();
1934        initial
1935            .service_types
1936            .insert("gvpn:exit".to_string(), service_type_info("gvpn:exit", Some("0x4444")));
1937
1938        let client = BlokliTestClient::new(initial, |_: &[u8], state: &mut BlokliTestState| {
1939            let info = state
1940                .service_types
1941                .get_mut("gvpn:exit")
1942                .ok_or_else(|| anyhow::anyhow!("missing service type"))
1943                .map_err(|e| crate::errors::ErrorKind::MockClientError(e))?;
1944            info.owner = None;
1945            info.update_burn = "5 wei wxHOPR".to_string();
1946            Result::Ok(())
1947        });
1948
1949        let mut stream = client.subscribe_service_types(Some(GVPN_EXIT))?;
1950        client.submit_transaction(&[0]).await?;
1951
1952        let updates = stream.by_ref().take(2).collect::<Vec<_>>().await;
1953        let updates = updates.into_iter().collect::<Result<Vec<_>>>()?;
1954
1955        assert_eq!(
1956            updates.iter().map(|update| update.kind).collect::<Vec<_>>(),
1957            vec![
1958                ServiceTypeUpdateKind::OwnerChanged,
1959                ServiceTypeUpdateKind::UpdateBurnChanged
1960            ]
1961        );
1962        insta::assert_yaml_snapshot!(updates);
1963        Ok(())
1964    }
1965
1966    #[cfg(feature = "curvy")]
1967    #[tokio::test]
1968    async fn curvy_test_client_methods_have_deterministic_defaults() -> anyhow::Result<()> {
1969        let client = BlokliTestClient::new(BlokliTestState::default(), NopStateMutator);
1970
1971        assert!(client.query_curvy_pending_notes(None, None, 1).await?.notes.is_empty());
1972        assert!(
1973            client
1974                .query_curvy_committed_notes(None, None, 1)
1975                .await?
1976                .notes
1977                .is_empty()
1978        );
1979        assert!(
1980            client
1981                .query_curvy_committed_nullifiers(None, None, 1)
1982                .await?
1983                .nullifiers
1984                .is_empty()
1985        );
1986
1987        assert!(client.query_curvy_sync_checkpoint(None).await.is_err());
1988        assert!(
1989            client
1990                .query_curvy_sync_notes("checkpoint".to_owned(), None, 1)
1991                .await
1992                .is_err()
1993        );
1994        assert!(
1995            client
1996                .query_curvy_sync_nullifiers("checkpoint".to_owned(), None, 1)
1997                .await
1998                .is_err()
1999        );
2000        assert!(
2001            client
2002                .query_curvy_shard_roots("checkpoint".to_owned(), None, 1)
2003                .await
2004                .is_err()
2005        );
2006        assert!(client.query_curvy_aggregator_state().await.is_err());
2007        assert!(client.query_curvy_note_status("note".to_owned()).await.is_err());
2008        assert!(client.query_curvy_valid_notes_root("root".to_owned()).await.is_err());
2009        assert!(
2010            client
2011                .query_curvy_nullifier_spent("nullifier".to_owned())
2012                .await
2013                .is_err()
2014        );
2015        assert!(client.query_curvy_vault_fees().await.is_err());
2016        assert!(client.query_curvy_aggregator_fees().await.is_err());
2017        assert!(client.query_curvy_vault_token_count().await.is_err());
2018        assert!(client.query_curvy_vault_token("1".to_owned()).await.is_err());
2019        assert!(
2020            client
2021                .query_curvy_entry_portal_address("owner".to_owned(), "recovery".to_owned())
2022                .await
2023                .is_err()
2024        );
2025        assert!(
2026            client
2027                .query_curvy_exit_portal_address("exit".to_owned(), "1".to_owned(), "recovery".to_owned(),)
2028                .await
2029                .is_err()
2030        );
2031        assert!(client.query_curvy_portal_registered("portal".to_owned()).await.is_err());
2032
2033        assert!(client.subscribe_curvy_pending_notes(None)?.next().await.is_none());
2034        assert!(client.subscribe_curvy_committed_notes(None)?.next().await.is_none());
2035        assert!(
2036            client
2037                .subscribe_curvy_committed_nullifiers(None)?
2038                .next()
2039                .await
2040                .is_none()
2041        );
2042
2043        Ok(())
2044    }
2045}