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