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::{ClientModuleMigrationFn, 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_hack(&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        // HACK: This is a terrible hack. The balance is set
357        // straight to the amount from the output, assuming that no funds were available
358        // before the receive. The actual state machine is supposed to update the
359        // balance, but `receive_money` is typically paired with `send_money`
360        // which creates a state machine only on the sender's client.
361        dbtx.insert_entry(&DummyClientFundsKeyV1, &outcome.0).await;
362        dbtx.commit_tx().await;
363
364        Ok(())
365    }
366
367    /// Return our account
368    pub fn account(&self) -> PublicKey {
369        self.key.public_key()
370    }
371}
372
373async fn get_funds(dbtx: &mut DatabaseTransaction<'_>) -> Amount {
374    let funds = dbtx.get_value(&DummyClientFundsKeyV1).await;
375    funds.unwrap_or(Amount::ZERO)
376}
377
378#[derive(Debug, Clone)]
379pub struct DummyClientInit;
380
381// TODO: Boilerplate-code
382impl ModuleInit for DummyClientInit {
383    type Common = DummyCommonInit;
384
385    async fn dump_database(
386        &self,
387        dbtx: &mut DatabaseTransaction<'_>,
388        prefix_names: Vec<String>,
389    ) -> Box<dyn Iterator<Item = (String, Box<dyn erased_serde::Serialize + Send>)> + '_> {
390        let mut items: BTreeMap<String, Box<dyn erased_serde::Serialize + Send>> = BTreeMap::new();
391        let filtered_prefixes = DbKeyPrefix::iter().filter(|f| {
392            prefix_names.is_empty() || prefix_names.contains(&f.to_string().to_lowercase())
393        });
394
395        for table in filtered_prefixes {
396            match table {
397                DbKeyPrefix::ClientFunds => {
398                    if let Some(funds) = dbtx.get_value(&DummyClientFundsKeyV1).await {
399                        items.insert("Dummy Funds".to_string(), Box::new(funds));
400                    }
401                }
402                DbKeyPrefix::ClientName => {
403                    if let Some(name) = dbtx.get_value(&DummyClientNameKey).await {
404                        items.insert("Dummy Name".to_string(), Box::new(name));
405                    }
406                }
407                DbKeyPrefix::ExternalReservedStart
408                | DbKeyPrefix::CoreInternalReservedStart
409                | DbKeyPrefix::CoreInternalReservedEnd => {}
410            }
411        }
412
413        Box::new(items.into_iter())
414    }
415}
416
417/// Generates the client module
418#[apply(async_trait_maybe_send!)]
419impl ClientModuleInit for DummyClientInit {
420    type Module = DummyClientModule;
421
422    fn supported_api_versions(&self) -> MultiApiVersion {
423        MultiApiVersion::try_from_iter([ApiVersion { major: 0, minor: 0 }])
424            .expect("no version conflicts")
425    }
426
427    async fn init(&self, args: &ClientModuleInitArgs<Self>) -> anyhow::Result<Self::Module> {
428        Ok(DummyClientModule {
429            cfg: args.cfg().clone(),
430            key: args
431                .module_root_secret()
432                .clone()
433                .to_secp_key(&Secp256k1::new()),
434
435            notifier: args.notifier().clone(),
436            client_ctx: args.context(),
437            db: args.db().clone(),
438        })
439    }
440
441    fn get_database_migrations(&self) -> BTreeMap<DatabaseVersion, ClientModuleMigrationFn> {
442        let mut migrations: BTreeMap<DatabaseVersion, ClientModuleMigrationFn> = BTreeMap::new();
443        migrations.insert(DatabaseVersion(0), |dbtx, _, _| {
444            Box::pin(migrate_to_v1(dbtx))
445        });
446
447        migrations.insert(DatabaseVersion(1), |_, active_states, inactive_states| {
448            Box::pin(async {
449                migrate_state(active_states, inactive_states, db::get_v1_migrated_state)
450            })
451        });
452
453        migrations
454    }
455}