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