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#[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 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 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 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 tweak_key: Keypair,
375 pub(crate) btc_transaction: bitcoin::Transaction,
378 pub(crate) out_idx: u32,
380}
381
382#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
383pub struct ClaimingDepositState {
384 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 {}