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::{DbKeyPrefix, DummyClientFundsKeyV1, DummyClientNameKey, migrate_to_v1};
14use fedimint_api_client::api::{FederationApiExt, SerdeOutputOutcome, deserialize_outcome};
15use fedimint_client_module::db::{ClientMigrationFn, migrate_state};
16use fedimint_client_module::module::init::{ClientModuleInit, ClientModuleInitArgs};
17use fedimint_client_module::module::recovery::NoModuleBackup;
18use fedimint_client_module::module::{ClientContext, ClientModule, IClientModule, OutPointRange};
19use fedimint_client_module::sm::{Context, ModuleNotifier};
20use fedimint_client_module::transaction::{
21    ClientInput, ClientInputBundle, ClientInputSM, ClientOutput, ClientOutputBundle,
22    ClientOutputSM, TransactionBuilder,
23};
24use fedimint_core::core::{Decoder, ModuleKind, OperationId};
25use fedimint_core::db::{
26    Database, DatabaseTransaction, DatabaseVersion, IDatabaseTransactionOpsCoreTyped,
27};
28#[allow(deprecated)]
29use fedimint_core::endpoint_constants::AWAIT_OUTPUT_OUTCOME_ENDPOINT;
30use fedimint_core::module::{
31    ApiRequestErased, ApiVersion, CommonModuleInit, ModuleCommon, ModuleInit, MultiApiVersion,
32};
33use fedimint_core::secp256k1::{Keypair, PublicKey, Secp256k1};
34use fedimint_core::util::{BoxStream, NextOrPending};
35use fedimint_core::{Amount, OutPoint, apply, async_trait_maybe_send};
36pub use fedimint_dummy_common as common;
37use fedimint_dummy_common::config::DummyClientConfig;
38use fedimint_dummy_common::{
39    DummyCommonInit, DummyInput, DummyModuleTypes, DummyOutput, DummyOutputOutcome, KIND,
40    fed_key_pair,
41};
42use futures::{StreamExt, pin_mut};
43use states::DummyStateMachine;
44use strum::IntoEnumIterator;
45
46pub mod api;
47pub mod db;
48pub mod states;
49
50#[derive(Debug)]
51pub struct DummyClientModule {
52    cfg: DummyClientConfig,
53    key: Keypair,
54    notifier: ModuleNotifier<DummyStateMachine>,
55    client_ctx: ClientContext<Self>,
56    db: Database,
57}
58
59/// Data needed by the state machine
60#[derive(Debug, Clone)]
61pub struct DummyClientContext {
62    pub dummy_decoder: Decoder,
63}
64
65// TODO: Boiler-plate
66impl Context for DummyClientContext {
67    const KIND: Option<ModuleKind> = None;
68}
69
70#[apply(async_trait_maybe_send!)]
71impl ClientModule for DummyClientModule {
72    type Init = DummyClientInit;
73    type Common = DummyModuleTypes;
74    type Backup = NoModuleBackup;
75    type ModuleStateMachineContext = DummyClientContext;
76    type States = DummyStateMachine;
77
78    fn context(&self) -> Self::ModuleStateMachineContext {
79        DummyClientContext {
80            dummy_decoder: self.decoder(),
81        }
82    }
83
84    fn input_fee(
85        &self,
86        _amount: Amount,
87        _input: &<Self::Common as ModuleCommon>::Input,
88    ) -> Option<Amount> {
89        Some(self.cfg.tx_fee)
90    }
91
92    fn output_fee(
93        &self,
94        _amount: Amount,
95        _output: &<Self::Common as ModuleCommon>::Output,
96    ) -> Option<Amount> {
97        Some(self.cfg.tx_fee)
98    }
99
100    fn supports_being_primary(&self) -> bool {
101        true
102    }
103
104    async fn create_final_inputs_and_outputs(
105        &self,
106        dbtx: &mut DatabaseTransaction<'_>,
107        operation_id: OperationId,
108        input_amount: Amount,
109        output_amount: Amount,
110    ) -> anyhow::Result<(
111        ClientInputBundle<DummyInput, DummyStateMachine>,
112        ClientOutputBundle<DummyOutput, DummyStateMachine>,
113    )> {
114        dbtx.ensure_isolated().expect("must be isolated");
115
116        match input_amount.cmp(&output_amount) {
117            Ordering::Less => {
118                let missing_input_amount = output_amount.saturating_sub(input_amount);
119
120                // Check and subtract from our funds
121                let our_funds = get_funds(dbtx).await;
122
123                if our_funds < missing_input_amount {
124                    return Err(format_err!("Insufficient funds"));
125                }
126
127                let updated = our_funds.saturating_sub(missing_input_amount);
128
129                dbtx.insert_entry(&DummyClientFundsKeyV1, &updated).await;
130
131                let input = ClientInput {
132                    input: DummyInput {
133                        amount: missing_input_amount,
134                        account: self.key.public_key(),
135                    },
136                    amount: missing_input_amount,
137                    keys: vec![self.key],
138                };
139                let input_sm = ClientInputSM {
140                    state_machines: Arc::new(move |out_point_range| {
141                        vec![DummyStateMachine::Input(
142                            missing_input_amount,
143                            out_point_range.txid(),
144                            operation_id,
145                        )]
146                    }),
147                };
148
149                Ok((
150                    ClientInputBundle::new(vec![input], vec![input_sm]),
151                    ClientOutputBundle::new(vec![], vec![]),
152                ))
153            }
154            Ordering::Equal => Ok((
155                ClientInputBundle::new(vec![], vec![]),
156                ClientOutputBundle::new(vec![], vec![]),
157            )),
158            Ordering::Greater => {
159                let missing_output_amount = input_amount.saturating_sub(output_amount);
160                let output = ClientOutput {
161                    output: DummyOutput {
162                        amount: missing_output_amount,
163                        account: self.key.public_key(),
164                    },
165                    amount: missing_output_amount,
166                };
167
168                let output_sm = ClientOutputSM {
169                    state_machines: Arc::new(move |out_point_range| {
170                        vec![DummyStateMachine::Output(
171                            missing_output_amount,
172                            out_point_range.txid(),
173                            operation_id,
174                        )]
175                    }),
176                };
177
178                Ok((
179                    ClientInputBundle::new(vec![], vec![]),
180                    ClientOutputBundle::new(vec![output], vec![output_sm]),
181                ))
182            }
183        }
184    }
185
186    async fn await_primary_module_output(
187        &self,
188        operation_id: OperationId,
189        out_point: OutPoint,
190    ) -> anyhow::Result<()> {
191        let stream = self
192            .notifier
193            .subscribe(operation_id)
194            .await
195            .filter_map(|state| async move {
196                match state {
197                    DummyStateMachine::OutputDone(_, txid, _) => {
198                        if txid != out_point.txid {
199                            return None;
200                        }
201                        Some(Ok(()))
202                    }
203                    DummyStateMachine::Refund(_) => Some(Err(anyhow::anyhow!(
204                        "Error occurred processing the dummy transaction"
205                    ))),
206                    _ => None,
207                }
208            });
209
210        pin_mut!(stream);
211
212        stream.next_or_pending().await
213    }
214
215    async fn get_balance(&self, dbtc: &mut DatabaseTransaction<'_>) -> Amount {
216        get_funds(dbtc).await
217    }
218
219    async fn subscribe_balance_changes(&self) -> BoxStream<'static, ()> {
220        Box::pin(
221            self.notifier
222                .subscribe_all_operations()
223                .filter_map(|state| async move {
224                    match state {
225                        DummyStateMachine::OutputDone(_, _, _)
226                        | DummyStateMachine::Input { .. }
227                        | DummyStateMachine::Refund(_) => Some(()),
228                        _ => None,
229                    }
230                }),
231        )
232    }
233}
234
235impl DummyClientModule {
236    pub async fn print_using_account(
237        &self,
238        amount: Amount,
239        account_kp: Keypair,
240    ) -> anyhow::Result<(OperationId, OutPoint)> {
241        let op_id = OperationId(rand::random());
242
243        // TODO: Building a tx could be easier
244        // Create input using the fed's account
245        let input = ClientInput {
246            input: DummyInput {
247                amount,
248                account: account_kp.public_key(),
249            },
250            amount,
251            keys: vec![account_kp],
252        };
253
254        // Build and send tx to the fed
255        // Will output to our primary client module
256        let tx = TransactionBuilder::new().with_inputs(
257            self.client_ctx
258                .make_client_inputs(ClientInputBundle::new_no_sm(vec![input])),
259        );
260        let meta_gen = |change_range: OutPointRange| OutPoint {
261            txid: change_range.txid(),
262            out_idx: 0,
263        };
264        let change_range = self
265            .client_ctx
266            .finalize_and_submit_transaction(op_id, KIND.as_str(), meta_gen, tx)
267            .await?;
268
269        // Wait for the output of the primary module
270        self.client_ctx
271            .await_primary_module_outputs(op_id, change_range.into_iter().collect())
272            .await
273            .context("Waiting for the output of print_using_account")?;
274
275        Ok((
276            op_id,
277            change_range
278                .into_iter()
279                .next()
280                .expect("At least one output"),
281        ))
282    }
283
284    /// Request the federation prints money for us
285    pub async fn print_money(&self, amount: Amount) -> anyhow::Result<(OperationId, OutPoint)> {
286        self.print_using_account(amount, fed_key_pair()).await
287    }
288
289    /// Use a broken printer to print a liability instead of money
290    /// If the federation is honest, should always fail
291    pub async fn print_liability(&self, amount: Amount) -> anyhow::Result<(OperationId, OutPoint)> {
292        self.print_using_account(amount, broken_fed_key_pair())
293            .await
294    }
295
296    /// Send money to another user
297    pub async fn send_money(&self, account: PublicKey, amount: Amount) -> anyhow::Result<OutPoint> {
298        self.db.ensure_isolated().expect("must be isolated");
299
300        let op_id = OperationId(rand::random());
301
302        // Create output using another account
303        let output = ClientOutput {
304            output: DummyOutput { amount, account },
305            amount,
306        };
307
308        // Build and send tx to the fed
309        let tx = TransactionBuilder::new().with_outputs(
310            self.client_ctx
311                .make_client_outputs(ClientOutputBundle::new_no_sm(vec![output])),
312        );
313
314        let meta_gen = |change_range: OutPointRange| OutPoint {
315            txid: change_range.txid(),
316            out_idx: 0,
317        };
318        let change_range = self
319            .client_ctx
320            .finalize_and_submit_transaction(op_id, DummyCommonInit::KIND.as_str(), meta_gen, tx)
321            .await?;
322
323        let tx_subscription = self.client_ctx.transaction_updates(op_id).await;
324
325        tx_subscription
326            .await_tx_accepted(change_range.txid())
327            .await
328            .map_err(|e| anyhow!(e))?;
329
330        Ok(OutPoint {
331            txid: change_range.txid(),
332            out_idx: 0,
333        })
334    }
335
336    /// Wait to receive money at an outpoint
337    pub async fn receive_money(&self, outpoint: OutPoint) -> anyhow::Result<()> {
338        let mut dbtx = self.db.begin_transaction().await;
339
340        #[allow(deprecated)]
341        let outcome = self
342            .client_ctx
343            .global_api()
344            .request_current_consensus::<SerdeOutputOutcome>(
345                AWAIT_OUTPUT_OUTCOME_ENDPOINT.to_owned(),
346                ApiRequestErased::new(outpoint),
347            )
348            .await?;
349
350        let outcome = deserialize_outcome::<DummyOutputOutcome>(&outcome, &self.decoder())?;
351
352        if outcome.1 != self.key.public_key() {
353            return Err(format_err!("Wrong account id"));
354        }
355
356        dbtx.insert_entry(&DummyClientFundsKeyV1, &outcome.0).await;
357        dbtx.commit_tx().await;
358
359        Ok(())
360    }
361
362    /// Return our account
363    pub fn account(&self) -> PublicKey {
364        self.key.public_key()
365    }
366}
367
368async fn get_funds(dbtx: &mut DatabaseTransaction<'_>) -> Amount {
369    let funds = dbtx.get_value(&DummyClientFundsKeyV1).await;
370    funds.unwrap_or(Amount::ZERO)
371}
372
373#[derive(Debug, Clone)]
374pub struct DummyClientInit;
375
376// TODO: Boilerplate-code
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                    if let Some(funds) = dbtx.get_value(&DummyClientFundsKeyV1).await {
394                        items.insert("Dummy Funds".to_string(), Box::new(funds));
395                    }
396                }
397                DbKeyPrefix::ClientName => {
398                    if let Some(name) = dbtx.get_value(&DummyClientNameKey).await {
399                        items.insert("Dummy Name".to_string(), Box::new(name));
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(&self, args: &ClientModuleInitArgs<Self>) -> anyhow::Result<Self::Module> {
423        Ok(DummyClientModule {
424            cfg: args.cfg().clone(),
425            key: args
426                .module_root_secret()
427                .clone()
428                .to_secp_key(&Secp256k1::new()),
429
430            notifier: args.notifier().clone(),
431            client_ctx: args.context(),
432            db: args.db().clone(),
433        })
434    }
435
436    fn get_database_migrations(&self) -> BTreeMap<DatabaseVersion, ClientMigrationFn> {
437        let mut migrations: BTreeMap<DatabaseVersion, ClientMigrationFn> = BTreeMap::new();
438        migrations.insert(DatabaseVersion(0), |dbtx, _, _| {
439            Box::pin(migrate_to_v1(dbtx))
440        });
441
442        migrations.insert(DatabaseVersion(1), |_, active_states, inactive_states| {
443            Box::pin(async {
444                migrate_state(active_states, inactive_states, db::get_v1_migrated_state)
445            })
446        });
447
448        migrations
449    }
450}