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::{OutPoint, TransactionId};
19use fedimint_logging::LOG_CLIENT_MODULE_WALLET;
20use fedimint_wallet_common::WalletInput;
21use fedimint_wallet_common::tweakable::Tweakable;
22use fedimint_wallet_common::txoproof::PegInProof;
23use tracing::{debug, instrument, trace, warn};
24
25use crate::WalletClientContext;
26use crate::api::WalletFederationApi;
27use crate::pegin_monitor::filter_onchain_deposit_outputs;
28
29const TRANSACTION_STATUS_FETCH_INTERVAL: Duration = Duration::from_secs(1);
30
31#[cfg_attr(doc, aquamarine::aquamarine)]
34#[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!("Error while awaiting btc tx submitting: {e}"),
119 }
120 sleep(TRANSACTION_STATUS_FETCH_INTERVAL).await;
121 }
122
123 for attempt in 0u32.. {
124 sleep(cmp::min(
125 TRANSACTION_STATUS_FETCH_INTERVAL * attempt,
126 Duration::from_mins(15),
127 ))
128 .await;
129
130 match context.rpc.get_script_history(&script).await {
131 Ok(received) => {
132 if received.len() > 1 {
134 warn!(
135 "More than one transaction was sent to deposit address, only considering the first one"
136 );
137 }
138
139 if let Some((transaction, out_idx)) =
140 filter_onchain_deposit_outputs(received.into_iter(), &script).next()
141 {
142 return (transaction, out_idx);
143 }
144
145 trace!("No transactions received yet for script {script:?}");
146 }
147 Err(e) => {
148 warn!("Error fetching transaction history for {script:?}: {e}");
149 }
150 }
151 }
152
153 unreachable!()
154}
155
156fn transition_tx_seen(
157 old_state: DepositStateMachine,
158 btc_transaction: bitcoin::Transaction,
159 out_idx: u32,
160) -> DepositStateMachine {
161 let DepositStateMachine {
162 operation_id,
163 state: old_state,
164 } = old_state;
165
166 match old_state {
167 DepositStates::Created(created_state) => DepositStateMachine {
168 operation_id,
169 state: DepositStates::WaitingForConfirmations(WaitingForConfirmationsDepositState {
170 tweak_key: created_state.tweak_key,
171 btc_transaction,
172 out_idx,
173 }),
174 },
175 state => panic!("Invalid previous state: {state:?}"),
176 }
177}
178
179async fn await_deposit_address_timeout(timeout_at: SystemTime) {
180 if let Ok(time_until_deadline) = timeout_at.duration_since(fedimint_core::time::now()) {
181 sleep(time_until_deadline).await;
182 }
183}
184
185fn transition_deposit_timeout(old_state: &DepositStateMachine) -> DepositStateMachine {
186 assert_matches!(
187 old_state.state,
188 DepositStates::Created(_),
189 "Invalid previous state"
190 );
191
192 DepositStateMachine {
193 operation_id: old_state.operation_id,
194 state: DepositStates::TimedOut(TimedOutDepositState {}),
195 }
196}
197
198#[instrument(target = LOG_CLIENT_MODULE_WALLET, skip_all, level = "debug")]
199async fn await_btc_transaction_confirmed(
200 context: WalletClientContext,
201 global_context: DynGlobalClientContext,
202 waiting_state: WaitingForConfirmationsDepositState,
203) -> (TxOutProof, ModuleConsensusVersion) {
204 loop {
205 let consensus_block_count = match global_context
208 .module_api()
209 .fetch_consensus_block_count()
210 .await
211 {
212 Ok(consensus_block_count) => consensus_block_count,
213 Err(e) => {
214 warn!("Failed to fetch consensus block count from federation: {e}");
215 sleep(TRANSACTION_STATUS_FETCH_INTERVAL).await;
216 continue;
217 }
218 };
219 debug!(consensus_block_count, "Fetched consensus block count");
220
221 let confirmation_block_count = match context
222 .rpc
223 .get_tx_block_height(&waiting_state.btc_transaction.compute_txid())
224 .await
225 {
226 Ok(Some(confirmation_height)) => Some(confirmation_height + 1),
227 Ok(None) => None,
228 Err(e) => {
229 warn!("Failed to fetch confirmation height: {e:?}");
230 sleep(TRANSACTION_STATUS_FETCH_INTERVAL).await;
231 continue;
232 }
233 };
234
235 debug!(
236 ?confirmation_block_count,
237 "Fetched confirmation block count"
238 );
239
240 if !confirmation_block_count.is_some_and(|confirmation_block_count| {
241 consensus_block_count >= confirmation_block_count
242 }) {
243 trace!(
244 "Not confirmed yet, confirmation_block_count={confirmation_block_count:?}, consensus_block_count={consensus_block_count}"
245 );
246 sleep(TRANSACTION_STATUS_FETCH_INTERVAL).await;
247 continue;
248 }
249
250 let txout_proof = match context
252 .rpc
253 .get_txout_proof(waiting_state.btc_transaction.compute_txid())
254 .await
255 {
256 Ok(txout_proof) => txout_proof,
257 Err(e) => {
258 warn!("Failed to fetch transaction proof: {e:?}");
259 sleep(TRANSACTION_STATUS_FETCH_INTERVAL).await;
260 continue;
261 }
262 };
263
264 debug!(proof_block_hash = ?txout_proof.block_header.block_hash(), "Generated merkle proof");
265
266 let consensus_version = match global_context.module_api().module_consensus_version().await {
267 Ok(version) => version,
268 Err(e) => {
269 warn!("Failed to fetch module_consensus_version: {e:?}");
270 sleep(TRANSACTION_STATUS_FETCH_INTERVAL).await;
271 continue;
272 }
273 };
274
275 return (txout_proof, consensus_version);
276 }
277}
278
279pub(crate) async fn transition_btc_tx_confirmed(
280 dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
281 global_context: DynGlobalClientContext,
282 old_state: DepositStateMachine,
283 (txout_proof, consensus_version): (TxOutProof, ModuleConsensusVersion),
284) -> DepositStateMachine {
285 let DepositStates::WaitingForConfirmations(awaiting_confirmation_state) = old_state.state
286 else {
287 panic!("Invalid previous state")
288 };
289
290 let pegin_proof = PegInProof::new(
291 txout_proof,
292 awaiting_confirmation_state.btc_transaction.clone(),
293 awaiting_confirmation_state.out_idx,
294 awaiting_confirmation_state.tweak_key.public_key(),
295 )
296 .expect("TODO: handle API returning faulty proofs");
297
298 let amount = Amounts::new_bitcoin(pegin_proof.tx_output().value.into());
299
300 let wallet_input = if consensus_version >= ModuleConsensusVersion::new(2, 2) {
301 WalletInput::new_v1(&pegin_proof)
302 } else {
303 WalletInput::new_v0(pegin_proof)
304 };
305
306 let client_input = ClientInput::<WalletInput> {
307 input: wallet_input,
308 keys: vec![awaiting_confirmation_state.tweak_key],
309 amounts: amount,
310 };
311
312 let change_range = global_context
313 .claim_inputs(dbtx, ClientInputBundle::new_no_sm(vec![client_input]))
314 .await
315 .expect("Cannot claim input, additional funding needed");
316
317 DepositStateMachine {
318 operation_id: old_state.operation_id,
319 state: DepositStates::Claiming(ClaimingDepositState {
320 transaction_id: change_range.txid(),
321 change: change_range.into_iter().collect(),
322 }),
323 }
324}
325
326#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
327pub enum DepositStates {
328 Created(CreatedDepositState),
329 WaitingForConfirmations(WaitingForConfirmationsDepositState),
330 Claiming(ClaimingDepositState),
331 TimedOut(TimedOutDepositState),
332}
333
334#[derive(Debug, Clone, Eq, PartialEq, Hash)]
335pub struct CreatedDepositState {
336 pub(crate) tweak_key: Keypair,
337 pub(crate) timeout_at: SystemTime,
338}
339
340impl Encodable for CreatedDepositState {
341 fn consensus_encode<W: std::io::Write>(&self, writer: &mut W) -> Result<(), std::io::Error> {
342 self.tweak_key.consensus_encode(writer)?;
343 encode_legacy_system_time(&self.timeout_at, writer)
344 }
345}
346
347impl Decodable for CreatedDepositState {
348 fn consensus_decode_partial_from_finite_reader<D: std::io::Read>(
349 decoder: &mut D,
350 modules: &ModuleDecoderRegistry,
351 ) -> Result<Self, DecodeError> {
352 Ok(Self {
353 tweak_key: decode_field_from_finite_reader(
354 decoder,
355 modules,
356 "Decoding named block field: CreatedDepositState{ ... tweak_key ... }",
357 )?,
358 timeout_at: with_decoding_context(
359 decode_legacy_system_time_from_finite_reader(decoder, modules),
360 "Decoding named block field: CreatedDepositState{ ... timeout_at ... }",
361 )?,
362 })
363 }
364}
365
366#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
367pub struct WaitingForConfirmationsDepositState {
368 tweak_key: Keypair,
372 pub(crate) btc_transaction: bitcoin::Transaction,
375 pub(crate) out_idx: u32,
377}
378
379#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
380pub struct ClaimingDepositState {
381 pub(crate) transaction_id: TransactionId,
383 pub(crate) change: Vec<OutPoint>,
384}
385
386#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
387pub struct TimedOutDepositState {}