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