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 anyhow::{Context as _, anyhow, format_err};
12use common::broken_fed_key_pair;
13use db::{
14    DbKeyPrefix, DummyClientFundsKey, DummyClientFundsKeyV1, DummyClientFundsKeyV2PrefixAll,
15    DummyClientNameKey, migrate_to_v1,
16};
17use fedimint_api_client::api::{FederationApiExt, SerdeOutputOutcome, deserialize_outcome};
18use fedimint_client_module::db::{ClientModuleMigrationFn, migrate_state};
19use fedimint_client_module::module::init::{ClientModuleInit, ClientModuleInitArgs};
20use fedimint_client_module::module::recovery::NoModuleBackup;
21use fedimint_client_module::module::{
22    ClientContext, ClientModule, IClientModule, OutPointRange, PrimaryModulePriority,
23    PrimaryModuleSupport,
24};
25use fedimint_client_module::sm::{Context, ModuleNotifier};
26use fedimint_client_module::transaction::{
27    ClientInput, ClientInputBundle, ClientInputSM, ClientOutput, ClientOutputBundle,
28    ClientOutputSM, TransactionBuilder,
29};
30use fedimint_core::core::{Decoder, ModuleKind, OperationId};
31use fedimint_core::db::{
32    Database, DatabaseTransaction, DatabaseVersion, IDatabaseTransactionOpsCoreTyped,
33};
34#[allow(deprecated)]
35use fedimint_core::endpoint_constants::AWAIT_OUTPUT_OUTCOME_ENDPOINT;
36use fedimint_core::module::{
37    AmountUnit, Amounts, ApiRequestErased, ApiVersion, CommonModuleInit, ModuleCommon, ModuleInit,
38    MultiApiVersion,
39};
40use fedimint_core::secp256k1::{Keypair, PublicKey, Secp256k1};
41use fedimint_core::util::{BoxStream, NextOrPending};
42use fedimint_core::{Amount, OutPoint, apply, async_trait_maybe_send};
43pub use fedimint_dummy_common as common;
44use fedimint_dummy_common::config::DummyClientConfig;
45use fedimint_dummy_common::{
46    DummyCommonInit, DummyInput, DummyInputV1, DummyModuleTypes, DummyOutput, DummyOutputOutcome,
47    DummyOutputV1, KIND, fed_key_pair,
48};
49use futures::{StreamExt, pin_mut};
50use states::DummyStateMachine;
51use strum::IntoEnumIterator;
52
53pub mod api;
54pub mod db;
55pub mod states;
56
57#[derive(Debug)]
58pub struct DummyClientModule {
59    cfg: DummyClientConfig,
60    key: Keypair,
61    notifier: ModuleNotifier<DummyStateMachine>,
62    client_ctx: ClientContext<Self>,
63    db: Database,
64}
65
66/// Data needed by the state machine
67#[derive(Debug, Clone)]
68pub struct DummyClientContext {
69    pub dummy_decoder: Decoder,
70}
71
72// TODO: Boiler-plate
73impl Context for DummyClientContext {
74    const KIND: Option<ModuleKind> = None;
75}
76
77#[apply(async_trait_maybe_send!)]
78impl ClientModule for DummyClientModule {
79    type Init = DummyClientInit;
80    type Common = DummyModuleTypes;
81    type Backup = NoModuleBackup;
82    type ModuleStateMachineContext = DummyClientContext;
83    type States = DummyStateMachine;
84
85    fn context(&self) -> Self::ModuleStateMachineContext {
86        DummyClientContext {
87            dummy_decoder: self.decoder(),
88        }
89    }
90
91    fn input_fee(
92        &self,
93        _amount: &Amounts,
94        _input: &<Self::Common as ModuleCommon>::Input,
95    ) -> Option<Amounts> {
96        Some(Amounts::new_bitcoin(self.cfg.tx_fee))
97    }
98
99    fn output_fee(
100        &self,
101        _amount: &Amounts,
102        _output: &<Self::Common as ModuleCommon>::Output,
103    ) -> Option<Amounts> {
104        Some(Amounts::new_bitcoin(self.cfg.tx_fee))
105    }
106
107    fn supports_being_primary(&self) -> PrimaryModuleSupport {
108        PrimaryModuleSupport::Any {
109            priority: PrimaryModulePriority::LOW,
110        }
111    }
112
113    async fn create_final_inputs_and_outputs(
114        &self,
115        dbtx: &mut DatabaseTransaction<'_>,
116        operation_id: OperationId,
117        unit: AmountUnit,
118        input_amount: Amount,
119        output_amount: Amount,
120    ) -> anyhow::Result<(
121        ClientInputBundle<DummyInput, DummyStateMachine>,
122        ClientOutputBundle<DummyOutput, DummyStateMachine>,
123    )> {
124        dbtx.ensure_isolated().expect("must be isolated");
125
126        match input_amount.cmp(&output_amount) {
127            Ordering::Less => {
128                let missing_input_amount = output_amount.saturating_sub(input_amount);
129
130                // Check and subtract from our funds
131                let our_funds = get_funds(dbtx, unit).await;
132
133                if our_funds < missing_input_amount {
134                    return Err(format_err!("Insufficient funds"));
135                }
136
137                let updated = our_funds.saturating_sub(missing_input_amount);
138                dbtx.insert_entry(&DummyClientFundsKey { unit }, &updated)
139                    .await;
140
141                let input = ClientInput {
142                    input: DummyInputV1 {
143                        amount: missing_input_amount,
144                        unit,
145                        account: self.key.public_key(),
146                    }
147                    .into(),
148                    amounts: Amounts::new_custom(unit, missing_input_amount),
149                    keys: vec![self.key],
150                };
151                let input_sm = ClientInputSM {
152                    state_machines: Arc::new(move |out_point_range| {
153                        vec![DummyStateMachine::Input(
154                            missing_input_amount,
155                            unit,
156                            out_point_range.txid(),
157                            operation_id,
158                        )]
159                    }),
160                };
161
162                Ok((
163                    ClientInputBundle::new(vec![input], vec![input_sm]),
164                    ClientOutputBundle::new(vec![], vec![]),
165                ))
166            }
167            Ordering::Equal => Ok((
168                ClientInputBundle::new(vec![], vec![]),
169                ClientOutputBundle::new(vec![], vec![]),
170            )),
171            Ordering::Greater => {
172                let missing_output_amount = input_amount.saturating_sub(output_amount);
173                let output = ClientOutput {
174                    output: DummyOutputV1 {
175                        amount: missing_output_amount,
176                        unit,
177                        account: self.key.public_key(),
178                    }
179                    .into(),
180                    amounts: Amounts::new_custom(unit, missing_output_amount),
181                };
182
183                let output_sm = ClientOutputSM {
184                    state_machines: Arc::new(move |out_point_range| {
185                        vec![DummyStateMachine::Output(
186                            missing_output_amount,
187                            unit,
188                            out_point_range.txid(),
189                            operation_id,
190                        )]
191                    }),
192                };
193
194                Ok((
195                    ClientInputBundle::new(vec![], vec![]),
196                    ClientOutputBundle::new(vec![output], vec![output_sm]),
197                ))
198            }
199        }
200    }
201
202    async fn await_primary_module_output(
203        &self,
204        operation_id: OperationId,
205        out_point: OutPoint,
206    ) -> anyhow::Result<()> {
207        let stream = self
208            .notifier
209            .subscribe(operation_id)
210            .await
211            .filter_map(|state| async move {
212                match state {
213                    DummyStateMachine::OutputDone(_, _, txid, _) => {
214                        if txid != out_point.txid {
215                            return None;
216                        }
217                        Some(Ok(()))
218                    }
219                    DummyStateMachine::Refund(_) => Some(Err(anyhow::anyhow!(
220                        "Error occurred processing the dummy transaction"
221                    ))),
222                    _ => None,
223                }
224            });
225
226        pin_mut!(stream);
227
228        stream.next_or_pending().await
229    }
230
231    async fn get_balance(&self, dbtc: &mut DatabaseTransaction<'_>, unit: AmountUnit) -> Amount {
232        get_funds(dbtc, unit).await
233    }
234
235    async fn get_balances(&self, dbtx: &mut DatabaseTransaction<'_>) -> Amounts {
236        get_funds_all(dbtx).await
237    }
238
239    async fn subscribe_balance_changes(&self) -> BoxStream<'static, ()> {
240        Box::pin(
241            self.notifier
242                .subscribe_all_operations()
243                .filter_map(|state| async move {
244                    match state {
245                        DummyStateMachine::OutputDone(_, _, _, _)
246                        | DummyStateMachine::Input { .. }
247                        | DummyStateMachine::Refund(_) => Some(()),
248                        _ => None,
249                    }
250                }),
251        )
252    }
253}
254
255impl DummyClientModule {
256    pub async fn print_money_units(
257        &self,
258        amount: Amount,
259        unit: AmountUnit,
260        account_kp: Keypair,
261    ) -> anyhow::Result<(OperationId, OutPoint)> {
262        let op_id = OperationId(rand::random());
263
264        // TODO: Building a tx could be easier
265        // Create input using the fed's account
266        let input = ClientInput::<DummyInput> {
267            input: DummyInputV1 {
268                amount,
269                unit,
270                account: account_kp.public_key(),
271            }
272            .into(),
273            amounts: Amounts::new_custom(unit, amount),
274            keys: vec![account_kp],
275        };
276
277        // Build and send tx to the fed
278        // Will output to our primary client module
279        let tx = TransactionBuilder::new().with_inputs(
280            self.client_ctx
281                .make_client_inputs(ClientInputBundle::new_no_sm(vec![input])),
282        );
283        let meta_gen = |change_range: OutPointRange| OutPoint {
284            txid: change_range.txid(),
285            out_idx: 0,
286        };
287        let change_range = self
288            .client_ctx
289            .finalize_and_submit_transaction(op_id, KIND.as_str(), meta_gen, tx)
290            .await?;
291
292        // Wait for the output of the primary module
293        self.client_ctx
294            .await_primary_module_outputs(op_id, change_range.into_iter().collect())
295            .await
296            .context("Waiting for the output of print_using_account")?;
297
298        Ok((
299            op_id,
300            change_range
301                .into_iter()
302                .next()
303                .expect("At least one output"),
304        ))
305    }
306
307    /// Request the federation prints money for us
308    pub async fn print_money(&self, amount: Amount) -> anyhow::Result<(OperationId, OutPoint)> {
309        self.print_money_units(amount, AmountUnit::BITCOIN, fed_key_pair())
310            .await
311    }
312
313    /// Use a broken printer to print a liability instead of money
314    /// If the federation is honest, should always fail
315    pub async fn print_liability(&self, amount: Amount) -> anyhow::Result<(OperationId, OutPoint)> {
316        self.print_money_units(amount, AmountUnit::BITCOIN, broken_fed_key_pair())
317            .await
318    }
319
320    /// Send money to another user
321    pub async fn send_money(
322        &self,
323        account: PublicKey,
324        amount: Amount,
325        unit: AmountUnit,
326    ) -> anyhow::Result<OutPoint> {
327        self.db.ensure_isolated().expect("must be isolated");
328
329        let op_id = OperationId(rand::random());
330
331        // Create output using another account
332        let output = ClientOutput::<DummyOutput> {
333            output: DummyOutputV1 {
334                amount,
335                unit,
336                account,
337            }
338            .into(),
339            amounts: Amounts::new_custom(unit, amount),
340        };
341
342        // Build and send tx to the fed
343        let tx = TransactionBuilder::new().with_outputs(
344            self.client_ctx
345                .make_client_outputs(ClientOutputBundle::new_no_sm(vec![output])),
346        );
347
348        let meta_gen = |change_range: OutPointRange| OutPoint {
349            txid: change_range.txid(),
350            out_idx: 0,
351        };
352        let change_range = self
353            .client_ctx
354            .finalize_and_submit_transaction(op_id, DummyCommonInit::KIND.as_str(), meta_gen, tx)
355            .await?;
356
357        let tx_subscription = self.client_ctx.transaction_updates(op_id).await;
358
359        tx_subscription
360            .await_tx_accepted(change_range.txid())
361            .await
362            .map_err(|e| anyhow!(e))?;
363
364        Ok(OutPoint {
365            txid: change_range.txid(),
366            out_idx: 0,
367        })
368    }
369
370    /// Wait to receive money at an outpoint
371    pub async fn receive_money_hack(&self, outpoint: OutPoint) -> anyhow::Result<()> {
372        let mut dbtx = self.db.begin_transaction().await;
373
374        #[allow(deprecated)]
375        let outcome = self
376            .client_ctx
377            .global_api()
378            .request_current_consensus::<SerdeOutputOutcome>(
379                AWAIT_OUTPUT_OUTCOME_ENDPOINT.to_owned(),
380                ApiRequestErased::new(outpoint),
381            )
382            .await?;
383
384        let outcome = deserialize_outcome::<DummyOutputOutcome>(&outcome, &self.decoder())?;
385
386        if outcome.2 != self.key.public_key() {
387            return Err(format_err!("Wrong account id"));
388        }
389
390        // HACK: This is a terrible hack. The balance for the unit is set
391        // straight to the amount from the output, assuming that no funds were available
392        // before. The actual state machine is supposed to update the balance,
393        // but `receive_money` is typically paired with `send_money` which
394        // creates a state machine only on the sender's client.
395        dbtx.insert_entry(&DummyClientFundsKey { unit: outcome.1 }, &outcome.0)
396            .await;
397
398        dbtx.commit_tx().await;
399
400        Ok(())
401    }
402
403    /// Return our account
404    pub fn account(&self) -> PublicKey {
405        self.key.public_key()
406    }
407
408    /// Get balance for a specific amount unit
409    pub async fn get_balance(&self, unit: AmountUnit) -> anyhow::Result<Amount> {
410        let mut dbtx = self.db.begin_transaction_nc().await;
411        Ok(get_funds(&mut dbtx, unit).await)
412    }
413}
414
415async fn get_funds(dbtx: &mut DatabaseTransaction<'_>, unit: AmountUnit) -> Amount {
416    let funds = dbtx.get_value(&DummyClientFundsKey { unit }).await;
417    funds.unwrap_or(Amount::ZERO)
418}
419
420async fn get_funds_all(dbtx: &mut DatabaseTransaction<'_>) -> Amounts {
421    use fedimint_core::db::IDatabaseTransactionOpsCoreTyped;
422
423    let funds_entries = dbtx
424        .find_by_prefix(&DummyClientFundsKeyV2PrefixAll)
425        .await
426        .collect::<Vec<_>>()
427        .await;
428
429    let mut result = Amounts::ZERO;
430    for (key, amount) in funds_entries {
431        if amount > Amount::ZERO {
432            result = result
433                .checked_add_unit(amount, key.unit)
434                .expect("We can't overfolow here");
435        }
436    }
437
438    result
439}
440
441#[derive(Debug, Clone)]
442pub struct DummyClientInit;
443
444// TODO: Boilerplate-code
445impl ModuleInit for DummyClientInit {
446    type Common = DummyCommonInit;
447
448    async fn dump_database(
449        &self,
450        dbtx: &mut DatabaseTransaction<'_>,
451        prefix_names: Vec<String>,
452    ) -> Box<dyn Iterator<Item = (String, Box<dyn erased_serde::Serialize + Send>)> + '_> {
453        let mut items: BTreeMap<String, Box<dyn erased_serde::Serialize + Send>> = BTreeMap::new();
454        let filtered_prefixes = DbKeyPrefix::iter().filter(|f| {
455            prefix_names.is_empty() || prefix_names.contains(&f.to_string().to_lowercase())
456        });
457
458        for table in filtered_prefixes {
459            match table {
460                DbKeyPrefix::ClientFunds => {
461                    if let Some(funds) = dbtx.get_value(&DummyClientFundsKeyV1).await {
462                        items.insert("Dummy Funds".to_string(), Box::new(funds));
463                    }
464                }
465                DbKeyPrefix::ClientName => {
466                    if let Some(name) = dbtx.get_value(&DummyClientNameKey).await {
467                        items.insert("Dummy Name".to_string(), Box::new(name));
468                    }
469                }
470                DbKeyPrefix::ExternalReservedStart
471                | DbKeyPrefix::CoreInternalReservedStart
472                | DbKeyPrefix::CoreInternalReservedEnd => {}
473            }
474        }
475
476        Box::new(items.into_iter())
477    }
478}
479
480/// Generates the client module
481#[apply(async_trait_maybe_send!)]
482impl ClientModuleInit for DummyClientInit {
483    type Module = DummyClientModule;
484
485    fn supported_api_versions(&self) -> MultiApiVersion {
486        MultiApiVersion::try_from_iter([ApiVersion { major: 0, minor: 0 }])
487            .expect("no version conflicts")
488    }
489
490    async fn init(&self, args: &ClientModuleInitArgs<Self>) -> anyhow::Result<Self::Module> {
491        Ok(DummyClientModule {
492            cfg: args.cfg().clone(),
493            key: args
494                .module_root_secret()
495                .clone()
496                .to_secp_key(&Secp256k1::new()),
497
498            notifier: args.notifier().clone(),
499            client_ctx: args.context(),
500            db: args.db().clone(),
501        })
502    }
503
504    fn get_database_migrations(&self) -> BTreeMap<DatabaseVersion, ClientModuleMigrationFn> {
505        let mut migrations: BTreeMap<DatabaseVersion, ClientModuleMigrationFn> = BTreeMap::new();
506        migrations.insert(DatabaseVersion(0), |dbtx, _, _| {
507            Box::pin(migrate_to_v1(dbtx))
508        });
509
510        migrations.insert(DatabaseVersion(1), |_, active_states, inactive_states| {
511            Box::pin(async {
512                migrate_state(active_states, inactive_states, db::get_v1_migrated_state)
513            })
514        });
515
516        migrations.insert(DatabaseVersion(2), |_, active_states, inactive_states| {
517            Box::pin(async {
518                migrate_state(active_states, inactive_states, db::get_v2_migrated_state)
519            })
520        });
521        migrations
522    }
523}