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