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#[cfg_attr(doc, aquamarine::aquamarine)]
30#[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 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 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 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 tweak_key: Keypair,
342 pub(crate) btc_transaction: bitcoin::Transaction,
345 pub(crate) out_idx: u32,
347}
348
349#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
350pub struct ClaimingDepositState {
351 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 {}