Skip to main content

fedimint_dummy_client/
lib.rs

1#![deny(clippy::pedantic)]
2#![allow(clippy::missing_errors_doc)]
3#![allow(clippy::missing_panics_doc)]
4#![allow(clippy::module_name_repetitions)]
5#![allow(clippy::must_use_candidate)]
6
7use core::cmp::Ordering;
8use std::collections::BTreeMap;
9use std::sync::Arc;
10
11use db::{DbKeyPrefix, DummyClientFundsKey, DummyClientFundsKeyPrefixAll};
12use fedimint_client_module::db::ClientModuleMigrationFn;
13use fedimint_client_module::error::{ClientModuleError, InsufficientBalanceError};
14use fedimint_client_module::module::init::{ClientModuleInit, ClientModuleInitArgs};
15use fedimint_client_module::module::recovery::NoModuleBackup;
16use fedimint_client_module::module::{
17    ClientContext, ClientModule, OutPointRange, PrimaryModulePriority, PrimaryModuleSupport,
18};
19use fedimint_client_module::sm::{Context, DynState, ModuleNotifier, State, StateTransition};
20use fedimint_client_module::transaction::{
21    ClientInput, ClientInputBundle, ClientInputSM, ClientOutput, ClientOutputBundle, ClientOutputSM,
22};
23use fedimint_client_module::{DynGlobalClientContext, sm_enum_variant_translation};
24use fedimint_core::core::{IntoDynInstance, ModuleInstanceId, ModuleKind, OperationId};
25use fedimint_core::db::{
26    Database, DatabaseTransaction, DatabaseVersion, IDatabaseTransactionOpsCoreTyped,
27};
28use fedimint_core::encoding::{Decodable, Encodable};
29use fedimint_core::module::{
30    AmountUnit, Amounts, ApiVersion, ModuleCommon, ModuleInit, MultiApiVersion,
31};
32use fedimint_core::secp256k1::{Keypair, Secp256k1};
33use fedimint_core::util::BoxStream;
34use fedimint_core::{Amount, OutPoint, apply, async_trait_maybe_send, push_db_pair_items};
35pub use fedimint_dummy_common as common;
36use fedimint_dummy_common::{DummyCommonInit, DummyInput, DummyModuleTypes, DummyOutput};
37use futures::StreamExt;
38use strum::IntoEnumIterator;
39use tokio::sync::watch;
40
41pub mod db;
42mod input_sm;
43mod output_sm;
44
45use input_sm::{DummyInputSMCommon, DummyInputSMState, DummyInputStateMachine};
46use output_sm::{DummyOutputSMCommon, DummyOutputSMState, DummyOutputStateMachine};
47
48/// Wrapper enum for all state machines in the dummy module
49#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
50pub enum DummyStateMachine {
51    Input(DummyInputStateMachine),
52    Output(DummyOutputStateMachine),
53}
54
55impl State for DummyStateMachine {
56    type ModuleContext = DummyClientContext;
57
58    fn transitions(
59        &self,
60        context: &Self::ModuleContext,
61        global_context: &DynGlobalClientContext,
62    ) -> Vec<StateTransition<Self>> {
63        match self {
64            DummyStateMachine::Input(sm) => {
65                sm_enum_variant_translation!(
66                    sm.transitions(context, global_context),
67                    DummyStateMachine::Input
68                )
69            }
70            DummyStateMachine::Output(sm) => {
71                sm_enum_variant_translation!(
72                    sm.transitions(context, global_context),
73                    DummyStateMachine::Output
74                )
75            }
76        }
77    }
78
79    fn operation_id(&self) -> OperationId {
80        match self {
81            DummyStateMachine::Input(sm) => sm.operation_id(),
82            DummyStateMachine::Output(sm) => sm.operation_id(),
83        }
84    }
85}
86
87impl IntoDynInstance for DummyStateMachine {
88    type DynType = DynState;
89
90    fn into_dyn(self, instance_id: ModuleInstanceId) -> Self::DynType {
91        DynState::from_typed(instance_id, self)
92    }
93}
94
95pub struct DummyClientModule {
96    key: Keypair,
97    db: Database,
98    notifier: ModuleNotifier<DummyStateMachine>,
99    client_ctx: ClientContext<Self>,
100    balance_update_sender: watch::Sender<()>,
101}
102
103impl std::fmt::Debug for DummyClientModule {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        f.debug_struct("DummyClientModule").finish_non_exhaustive()
106    }
107}
108
109/// Data needed by the state machine
110#[derive(Clone)]
111pub struct DummyClientContext {
112    pub balance_update_sender: watch::Sender<()>,
113}
114
115impl std::fmt::Debug for DummyClientContext {
116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        f.debug_struct("DummyClientContext").finish_non_exhaustive()
118    }
119}
120
121impl Context for DummyClientContext {
122    const KIND: Option<ModuleKind> = None;
123}
124
125#[apply(async_trait_maybe_send!)]
126impl ClientModule for DummyClientModule {
127    type Init = DummyClientInit;
128    type Common = DummyModuleTypes;
129    type Backup = NoModuleBackup;
130    type ModuleStateMachineContext = DummyClientContext;
131    type States = DummyStateMachine;
132
133    fn context(&self) -> Self::ModuleStateMachineContext {
134        DummyClientContext {
135            balance_update_sender: self.balance_update_sender.clone(),
136        }
137    }
138
139    fn input_fee(
140        &self,
141        _amount: &Amounts,
142        _input: &<Self::Common as ModuleCommon>::Input,
143    ) -> Option<Amounts> {
144        Some(Amounts::ZERO)
145    }
146
147    fn output_fee(
148        &self,
149        _amount: &Amounts,
150        _output: &<Self::Common as ModuleCommon>::Output,
151    ) -> Option<Amounts> {
152        Some(Amounts::ZERO)
153    }
154
155    fn supports_being_primary(&self) -> PrimaryModuleSupport {
156        PrimaryModuleSupport::Any {
157            priority: PrimaryModulePriority::LOW,
158        }
159    }
160
161    async fn create_final_inputs_and_outputs(
162        &self,
163        dbtx: &mut DatabaseTransaction<'_>,
164        operation_id: OperationId,
165        unit: AmountUnit,
166        input_amount: Amount,
167        output_amount: Amount,
168    ) -> Result<
169        (
170            ClientInputBundle<DummyInput, DummyStateMachine>,
171            ClientOutputBundle<DummyOutput, DummyStateMachine>,
172        ),
173        ClientModuleError,
174    > {
175        dbtx.ensure_isolated().expect("must be isolated");
176
177        match input_amount.cmp(&output_amount) {
178            Ordering::Less => {
179                // Spending: balance subtracted immediately, refund on rejection
180                let missing_input_amount = output_amount.saturating_sub(input_amount);
181
182                let our_funds = get_funds(dbtx, unit).await;
183
184                if our_funds < missing_input_amount {
185                    return Err(InsufficientBalanceError {
186                        requested_amount: missing_input_amount,
187                        total_amount: our_funds,
188                    }
189                    .into());
190                }
191
192                let updated = our_funds.saturating_sub(missing_input_amount);
193
194                dbtx.insert_entry(&DummyClientFundsKey(unit), &updated)
195                    .await;
196
197                let sender = self.balance_update_sender.clone();
198
199                dbtx.on_commit(move || sender.send_replace(()));
200
201                let input = ClientInput {
202                    input: DummyInput {
203                        amount: missing_input_amount,
204                        unit,
205                        pub_key: self.key.public_key(),
206                    },
207                    amounts: Amounts::new_custom(unit, missing_input_amount),
208                    keys: vec![self.key],
209                };
210
211                let input_sm = ClientInputSM {
212                    state_machines: Arc::new(move |out_point_range: OutPointRange| {
213                        out_point_range
214                            .into_iter()
215                            .map(|out_point| {
216                                DummyStateMachine::Input(DummyInputStateMachine {
217                                    common: DummyInputSMCommon {
218                                        operation_id,
219                                        out_point,
220                                        amount: missing_input_amount,
221                                        unit,
222                                    },
223                                    state: DummyInputSMState::Created,
224                                })
225                            })
226                            .collect()
227                    }),
228                };
229
230                Ok((
231                    ClientInputBundle::new(vec![input], vec![input_sm]),
232                    ClientOutputBundle::new(vec![], vec![]),
233                ))
234            }
235            Ordering::Equal => Ok((
236                ClientInputBundle::new(vec![], vec![]),
237                ClientOutputBundle::new(vec![], vec![]),
238            )),
239            Ordering::Greater => {
240                // Receiving: balance added only on acceptance
241                let missing_output_amount = input_amount.saturating_sub(output_amount);
242
243                let output = ClientOutput {
244                    output: DummyOutput {
245                        amount: missing_output_amount,
246                        unit,
247                    },
248                    amounts: Amounts::new_custom(unit, missing_output_amount),
249                };
250
251                let output_sm = ClientOutputSM {
252                    state_machines: Arc::new(move |out_point_range: OutPointRange| {
253                        out_point_range
254                            .into_iter()
255                            .map(|out_point| {
256                                DummyStateMachine::Output(DummyOutputStateMachine {
257                                    common: DummyOutputSMCommon {
258                                        operation_id,
259                                        out_point,
260                                        amount: missing_output_amount,
261                                        unit,
262                                    },
263                                    state: DummyOutputSMState::Created,
264                                })
265                            })
266                            .collect()
267                    }),
268                };
269
270                Ok((
271                    ClientInputBundle::new(vec![], vec![]),
272                    ClientOutputBundle::new(vec![output], vec![output_sm]),
273                ))
274            }
275        }
276    }
277
278    async fn await_primary_module_output(
279        &self,
280        operation_id: OperationId,
281        out_point: OutPoint,
282    ) -> Result<(), ClientModuleError> {
283        let mut stream = self.notifier.subscribe(operation_id).await;
284
285        loop {
286            let DummyStateMachine::Output(output_sm) = stream
287                .next()
288                .await
289                .expect("Stream should not end before reaching final state")
290            else {
291                continue;
292            };
293
294            if output_sm.common.out_point != out_point {
295                continue;
296            }
297
298            match output_sm.state {
299                DummyOutputSMState::Created => {}
300                DummyOutputSMState::Accepted => return Ok(()),
301                DummyOutputSMState::Rejected => {
302                    return Err(ClientModuleError::other("Transaction was rejected"));
303                }
304            }
305        }
306    }
307
308    async fn get_balance(&self, dbtc: &mut DatabaseTransaction<'_>, unit: AmountUnit) -> Amount {
309        get_funds(dbtc, unit).await
310    }
311
312    async fn get_balances(&self, dbtx: &mut DatabaseTransaction<'_>) -> Amounts {
313        get_funds_all(dbtx).await
314    }
315
316    async fn subscribe_balance_changes(&self) -> BoxStream<'static, ()> {
317        Box::pin(tokio_stream::wrappers::WatchStream::new(
318            self.balance_update_sender.subscribe(),
319        ))
320    }
321}
322
323impl DummyClientModule {
324    /// The dummy server accepts any public key, so this can be used to create
325    /// funds out of thin air that get converted to e-cash as change.
326    pub fn create_input(&self, amount: Amount) -> ClientInputBundle {
327        let keypair = Keypair::new(&Secp256k1::new(), &mut rand::rngs::OsRng);
328
329        let client_input = ClientInput {
330            input: DummyInput {
331                amount,
332                unit: AmountUnit::BITCOIN,
333                pub_key: keypair.public_key(),
334            },
335            amounts: Amounts::new_bitcoin(amount),
336            keys: vec![keypair],
337        };
338
339        self.client_ctx
340            .make_client_inputs(ClientInputBundle::new_no_sm(vec![client_input]))
341    }
342
343    /// Add funds to the local balance (for testing)
344    pub async fn mock_receive(&self, amount: Amount, unit: AmountUnit) {
345        let mut dbtx = self.db.begin_transaction().await;
346
347        let current = dbtx
348            .get_value(&DummyClientFundsKey(unit))
349            .await
350            .unwrap_or(Amount::ZERO);
351
352        dbtx.insert_entry(&DummyClientFundsKey(unit), &(current + amount))
353            .await;
354
355        dbtx.commit_tx().await;
356    }
357}
358
359async fn get_funds(dbtx: &mut DatabaseTransaction<'_>, unit: AmountUnit) -> Amount {
360    dbtx.get_value(&DummyClientFundsKey(unit))
361        .await
362        .unwrap_or(Amount::ZERO)
363}
364
365async fn get_funds_all(dbtx: &mut DatabaseTransaction<'_>) -> Amounts {
366    dbtx.find_by_prefix(&DummyClientFundsKeyPrefixAll)
367        .await
368        .fold(Amounts::ZERO, |acc, (key, amount)| async move {
369            acc.checked_add_unit(amount, key.0).expect("can't overflow")
370        })
371        .await
372}
373
374#[derive(Debug, Clone)]
375pub struct DummyClientInit;
376
377impl ModuleInit for DummyClientInit {
378    type Common = DummyCommonInit;
379
380    async fn dump_database(
381        &self,
382        dbtx: &mut DatabaseTransaction<'_>,
383        prefix_names: Vec<String>,
384    ) -> Box<dyn Iterator<Item = (String, Box<dyn erased_serde::Serialize + Send>)> + '_> {
385        let mut items: BTreeMap<String, Box<dyn erased_serde::Serialize + Send>> = BTreeMap::new();
386        let filtered_prefixes = DbKeyPrefix::iter().filter(|f| {
387            prefix_names.is_empty() || prefix_names.contains(&f.to_string().to_lowercase())
388        });
389
390        for table in filtered_prefixes {
391            match table {
392                DbKeyPrefix::ClientFunds => {
393                    push_db_pair_items!(
394                        dbtx,
395                        DummyClientFundsKeyPrefixAll,
396                        DummyClientFundsKey,
397                        Amount,
398                        items,
399                        "Dummy Funds"
400                    );
401                }
402                DbKeyPrefix::ExternalReservedStart
403                | DbKeyPrefix::CoreInternalReservedStart
404                | DbKeyPrefix::CoreInternalReservedEnd => {}
405            }
406        }
407
408        Box::new(items.into_iter())
409    }
410}
411
412/// Generates the client module
413#[apply(async_trait_maybe_send!)]
414impl ClientModuleInit for DummyClientInit {
415    type Module = DummyClientModule;
416
417    fn supported_api_versions(&self) -> MultiApiVersion {
418        MultiApiVersion::try_from_iter([ApiVersion { major: 0, minor: 0 }])
419            .expect("no version conflicts")
420    }
421
422    async fn init(
423        &self,
424        args: &ClientModuleInitArgs<Self>,
425    ) -> Result<Self::Module, ClientModuleError> {
426        Ok(DummyClientModule {
427            key: args
428                .module_root_secret()
429                .clone()
430                .to_secp_key(&Secp256k1::new()),
431            db: args.db().clone(),
432            notifier: args.notifier().clone(),
433            client_ctx: args.context(),
434            balance_update_sender: watch::channel(()).0,
435        })
436    }
437
438    fn get_database_migrations(&self) -> BTreeMap<DatabaseVersion, ClientModuleMigrationFn> {
439        BTreeMap::new()
440    }
441}