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