fedimint_wallet_client/
deposit.rs

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