Skip to main content

fedimint_wallet_client/
deposit.rs

1use std::cmp;
2use std::time::{Duration, SystemTime};
3
4use assert_matches::assert_matches;
5use fedimint_client_module::DynGlobalClientContext;
6use fedimint_client_module::sm::{ClientSMDatabaseTransaction, State, StateTransition};
7use fedimint_client_module::transaction::{ClientInput, ClientInputBundle};
8use fedimint_core::core::OperationId;
9use fedimint_core::encoding::{
10    Decodable, DecodeError, Encodable, decode_field_from_finite_reader,
11    decode_legacy_system_time_from_finite_reader, encode_legacy_system_time, with_decoding_context,
12};
13use fedimint_core::module::registry::ModuleDecoderRegistry;
14use fedimint_core::module::{Amounts, ModuleConsensusVersion};
15use fedimint_core::secp256k1::Keypair;
16use fedimint_core::task::sleep;
17use fedimint_core::txoproof::TxOutProof;
18use fedimint_core::util::FmtCompact as _;
19use fedimint_core::{OutPoint, TransactionId};
20use fedimint_logging::LOG_CLIENT_MODULE_WALLET;
21use fedimint_wallet_common::WalletInput;
22use fedimint_wallet_common::tweakable::Tweakable;
23use fedimint_wallet_common::txoproof::PegInProof;
24use tracing::{debug, instrument, trace, warn};
25
26use crate::WalletClientContext;
27use crate::api::WalletFederationApi;
28use crate::pegin_monitor::filter_onchain_deposit_outputs;
29
30const TRANSACTION_STATUS_FETCH_INTERVAL: Duration = Duration::from_secs(1);
31
32// FIXME: deal with RBF
33// FIXME: deal with multiple deposits
34#[cfg_attr(doc, aquamarine::aquamarine)]
35/// The state machine driving forward a deposit (aka peg-in).
36///
37/// ```mermaid
38/// graph LR
39///     Created -- Transaction seen --> AwaitingConfirmations["Waiting for confirmations"]
40///     AwaitingConfirmations -- Confirmations received --> Claiming
41///     AwaitingConfirmations -- "Retransmit seen tx (planned)" --> AwaitingConfirmations
42///     Created -- "No transactions seen for [time]" --> Timeout["Timed out"]
43/// ```
44#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
45pub struct DepositStateMachine {
46    pub(crate) operation_id: OperationId,
47    pub(crate) state: DepositStates,
48}
49
50impl State for DepositStateMachine {
51    type ModuleContext = WalletClientContext;
52
53    fn transitions(
54        &self,
55        context: &Self::ModuleContext,
56        global_context: &DynGlobalClientContext,
57    ) -> Vec<StateTransition<Self>> {
58        match &self.state {
59            DepositStates::Created(created_state) => {
60                vec![
61                    StateTransition::new(
62                        await_created_btc_transaction_submitted(
63                            context.clone(),
64                            created_state.tweak_key,
65                        ),
66                        |_db, (btc_tx, out_idx), old_state| {
67                            Box::pin(async move { transition_tx_seen(old_state, btc_tx, out_idx) })
68                        },
69                    ),
70                    StateTransition::new(
71                        await_deposit_address_timeout(created_state.timeout_at),
72                        |_db, (), old_state| {
73                            Box::pin(async move { transition_deposit_timeout(&old_state) })
74                        },
75                    ),
76                ]
77            }
78            DepositStates::WaitingForConfirmations(waiting_state) => {
79                let global_context = global_context.clone();
80                vec![StateTransition::new(
81                    await_btc_transaction_confirmed(
82                        context.clone(),
83                        global_context.clone(),
84                        waiting_state.clone(),
85                    ),
86                    move |dbtx, txout_proof, old_state| {
87                        Box::pin(transition_btc_tx_confirmed(
88                            dbtx,
89                            global_context.clone(),
90                            old_state,
91                            txout_proof,
92                        ))
93                    },
94                )]
95            }
96            DepositStates::Claiming(_) | DepositStates::TimedOut(_) => {
97                vec![]
98            }
99        }
100    }
101
102    fn operation_id(&self) -> OperationId {
103        self.operation_id
104    }
105}
106
107async fn await_created_btc_transaction_submitted(
108    context: WalletClientContext,
109    tweak: Keypair,
110) -> (bitcoin::Transaction, u32) {
111    let script = context
112        .wallet_descriptor
113        .tweak(&tweak.public_key(), &context.secp)
114        .script_pubkey();
115
116    loop {
117        match context.rpc.watch_script_history(&script).await {
118            Ok(()) => break,
119            Err(e) => warn!(
120                "Error while awaiting btc tx submitting: {}",
121                e.fmt_compact()
122            ),
123        }
124        sleep(TRANSACTION_STATUS_FETCH_INTERVAL).await;
125    }
126
127    for attempt in 0u32.. {
128        sleep(cmp::min(
129            TRANSACTION_STATUS_FETCH_INTERVAL * attempt,
130            Duration::from_mins(15),
131        ))
132        .await;
133
134        match context.rpc.get_script_history(&script).await {
135            Ok(received) => {
136                // TODO: fix
137                if received.len() > 1 {
138                    warn!(
139                        "More than one transaction was sent to deposit address, only considering the first one"
140                    );
141                }
142
143                if let Some((transaction, out_idx)) =
144                    filter_onchain_deposit_outputs(received.into_iter(), &script).next()
145                {
146                    return (transaction, out_idx);
147                }
148
149                trace!("No transactions received yet for script {script:?}");
150            }
151            Err(e) => {
152                warn!("Error fetching transaction history for {script:?}: {e}");
153            }
154        }
155    }
156
157    unreachable!()
158}
159
160fn transition_tx_seen(
161    old_state: DepositStateMachine,
162    btc_transaction: bitcoin::Transaction,
163    out_idx: u32,
164) -> DepositStateMachine {
165    let DepositStateMachine {
166        operation_id,
167        state: old_state,
168    } = old_state;
169
170    match old_state {
171        DepositStates::Created(created_state) => DepositStateMachine {
172            operation_id,
173            state: DepositStates::WaitingForConfirmations(WaitingForConfirmationsDepositState {
174                tweak_key: created_state.tweak_key,
175                btc_transaction,
176                out_idx,
177            }),
178        },
179        state => panic!("Invalid previous state: {state:?}"),
180    }
181}
182
183async fn await_deposit_address_timeout(timeout_at: SystemTime) {
184    if let Ok(time_until_deadline) = timeout_at.duration_since(fedimint_core::time::now()) {
185        sleep(time_until_deadline).await;
186    }
187}
188
189fn transition_deposit_timeout(old_state: &DepositStateMachine) -> DepositStateMachine {
190    assert_matches!(
191        old_state.state,
192        DepositStates::Created(_),
193        "Invalid previous state"
194    );
195
196    DepositStateMachine {
197        operation_id: old_state.operation_id,
198        state: DepositStates::TimedOut(TimedOutDepositState {}),
199    }
200}
201
202#[instrument(target = LOG_CLIENT_MODULE_WALLET, skip_all, level = "debug")]
203async fn await_btc_transaction_confirmed(
204    context: WalletClientContext,
205    global_context: DynGlobalClientContext,
206    waiting_state: WaitingForConfirmationsDepositState,
207) -> (TxOutProof, ModuleConsensusVersion) {
208    loop {
209        // TODO: make everything subscriptions
210        // Wait for confirmation
211        let consensus_block_count = match global_context
212            .module_api()
213            .fetch_consensus_block_count()
214            .await
215        {
216            Ok(consensus_block_count) => consensus_block_count,
217            Err(e) => {
218                warn!("Failed to fetch consensus block count from federation: {e}");
219                sleep(TRANSACTION_STATUS_FETCH_INTERVAL).await;
220                continue;
221            }
222        };
223        debug!(consensus_block_count, "Fetched consensus block count");
224
225        let confirmation_block_count = match context
226            .rpc
227            .get_tx_block_height(&waiting_state.btc_transaction.compute_txid())
228            .await
229        {
230            Ok(Some(confirmation_height)) => Some(confirmation_height + 1),
231            Ok(None) => None,
232            Err(e) => {
233                warn!("Failed to fetch confirmation height: {}", e.fmt_compact());
234                sleep(TRANSACTION_STATUS_FETCH_INTERVAL).await;
235                continue;
236            }
237        };
238
239        debug!(
240            ?confirmation_block_count,
241            "Fetched confirmation block count"
242        );
243
244        if !confirmation_block_count.is_some_and(|confirmation_block_count| {
245            consensus_block_count >= confirmation_block_count
246        }) {
247            trace!(
248                "Not confirmed yet, confirmation_block_count={confirmation_block_count:?}, consensus_block_count={consensus_block_count}"
249            );
250            sleep(TRANSACTION_STATUS_FETCH_INTERVAL).await;
251            continue;
252        }
253
254        // Get txout proof
255        let txout_proof = match context
256            .rpc
257            .get_txout_proof(waiting_state.btc_transaction.compute_txid())
258            .await
259        {
260            Ok(txout_proof) => txout_proof,
261            Err(e) => {
262                warn!("Failed to fetch transaction proof: {}", e.fmt_compact());
263                sleep(TRANSACTION_STATUS_FETCH_INTERVAL).await;
264                continue;
265            }
266        };
267
268        debug!(proof_block_hash = ?txout_proof.block_header.block_hash(), "Generated merkle proof");
269
270        let consensus_version = match global_context.module_api().module_consensus_version().await {
271            Ok(version) => version,
272            Err(e) => {
273                warn!("Failed to fetch module_consensus_version: {e:?}");
274                sleep(TRANSACTION_STATUS_FETCH_INTERVAL).await;
275                continue;
276            }
277        };
278
279        return (txout_proof, consensus_version);
280    }
281}
282
283pub(crate) async fn transition_btc_tx_confirmed(
284    dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
285    global_context: DynGlobalClientContext,
286    old_state: DepositStateMachine,
287    (txout_proof, consensus_version): (TxOutProof, ModuleConsensusVersion),
288) -> DepositStateMachine {
289    let DepositStates::WaitingForConfirmations(awaiting_confirmation_state) = old_state.state
290    else {
291        panic!("Invalid previous state")
292    };
293
294    let pegin_proof = PegInProof::new(
295        txout_proof,
296        awaiting_confirmation_state.btc_transaction.clone(),
297        awaiting_confirmation_state.out_idx,
298        awaiting_confirmation_state.tweak_key.public_key(),
299    )
300    .expect("TODO: handle API returning faulty proofs");
301
302    let amount = Amounts::new_bitcoin(pegin_proof.tx_output().value.into());
303
304    let wallet_input = if consensus_version >= ModuleConsensusVersion::new(2, 2) {
305        WalletInput::new_v1(&pegin_proof)
306    } else {
307        WalletInput::new_v0(pegin_proof)
308    };
309
310    let client_input = ClientInput::<WalletInput> {
311        input: wallet_input,
312        keys: vec![awaiting_confirmation_state.tweak_key],
313        amounts: amount,
314    };
315
316    let change_range = global_context
317        .claim_inputs(dbtx, ClientInputBundle::new_no_sm(vec![client_input]))
318        .await
319        .expect("Cannot claim input, additional funding needed");
320
321    DepositStateMachine {
322        operation_id: old_state.operation_id,
323        state: DepositStates::Claiming(ClaimingDepositState {
324            transaction_id: change_range.txid(),
325            change: change_range.into_iter().collect(),
326        }),
327    }
328}
329
330#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
331pub enum DepositStates {
332    Created(CreatedDepositState),
333    WaitingForConfirmations(WaitingForConfirmationsDepositState),
334    Claiming(ClaimingDepositState),
335    TimedOut(TimedOutDepositState),
336}
337
338#[derive(Debug, Clone, Eq, PartialEq, Hash)]
339pub struct CreatedDepositState {
340    pub(crate) tweak_key: Keypair,
341    pub(crate) timeout_at: SystemTime,
342}
343
344impl Encodable for CreatedDepositState {
345    fn consensus_encode<W: std::io::Write>(&self, writer: &mut W) -> Result<(), std::io::Error> {
346        self.tweak_key.consensus_encode(writer)?;
347        encode_legacy_system_time(&self.timeout_at, writer)
348    }
349}
350
351impl Decodable for CreatedDepositState {
352    fn consensus_decode_partial_from_finite_reader<D: std::io::Read>(
353        decoder: &mut D,
354        modules: &ModuleDecoderRegistry,
355    ) -> Result<Self, DecodeError> {
356        Ok(Self {
357            tweak_key: decode_field_from_finite_reader(
358                decoder,
359                modules,
360                "Decoding named block field: CreatedDepositState{ ... tweak_key ... }",
361            )?,
362            timeout_at: with_decoding_context(
363                decode_legacy_system_time_from_finite_reader(decoder, modules),
364                "Decoding named block field: CreatedDepositState{ ... timeout_at ... }",
365            )?,
366        })
367    }
368}
369
370#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
371pub struct WaitingForConfirmationsDepositState {
372    /// Key pair of which the public was used to tweak the federation's wallet
373    /// descriptor. The secret key is later used to sign the fedimint claim
374    /// transaction.
375    tweak_key: Keypair,
376    /// The bitcoin transaction is saved as soon as we see it so the transaction
377    /// can be re-transmitted if it's evicted from the mempool.
378    pub(crate) btc_transaction: bitcoin::Transaction,
379    /// Index of the deposit output
380    pub(crate) out_idx: u32,
381}
382
383#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
384pub struct ClaimingDepositState {
385    /// Fedimint transaction id in which the deposit is being claimed.
386    pub(crate) transaction_id: TransactionId,
387    pub(crate) change: Vec<OutPoint>,
388}
389
390#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
391pub struct TimedOutDepositState {}