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 loop {
110 match context.rpc.watch_script_history(&script).await {
111 Ok(()) => break,
112 Err(e) => warn!("Error while awaiting btc tx submitting: {e}"),
113 }
114 sleep(TRANSACTION_STATUS_FETCH_INTERVAL).await;
115 }
116 for attempt in 0u32.. {
117 sleep(cmp::min(
118 TRANSACTION_STATUS_FETCH_INTERVAL * attempt,
119 Duration::from_secs(60 * 15),
120 ))
121 .await;
122
123 match context.rpc.get_script_history(&script).await {
124 Ok(received) => {
125 if received.len() > 1 {
127 warn!(
128 "More than one transaction was sent to deposit address, only considering the first one"
129 );
130 }
131
132 if let Some((transaction, out_idx)) =
133 filter_onchain_deposit_outputs(received.into_iter(), &script).next()
134 {
135 return (transaction, out_idx);
136 }
137
138 trace!("No transactions received yet for script {script:?}");
139 }
140 Err(e) => {
141 warn!("Error fetching transaction history for {script:?}: {e}");
142 }
143 }
144 }
145
146 unreachable!()
147}
148
149fn transition_tx_seen(
150 old_state: DepositStateMachine,
151 btc_transaction: bitcoin::Transaction,
152 out_idx: u32,
153) -> DepositStateMachine {
154 let DepositStateMachine {
155 operation_id,
156 state: old_state,
157 } = old_state;
158
159 match old_state {
160 DepositStates::Created(created_state) => DepositStateMachine {
161 operation_id,
162 state: DepositStates::WaitingForConfirmations(WaitingForConfirmationsDepositState {
163 tweak_key: created_state.tweak_key,
164 btc_transaction,
165 out_idx,
166 }),
167 },
168 state => panic!("Invalid previous state: {state:?}"),
169 }
170}
171
172async fn await_deposit_address_timeout(timeout_at: SystemTime) {
173 if let Ok(time_until_deadline) = timeout_at.duration_since(fedimint_core::time::now()) {
174 sleep(time_until_deadline).await;
175 }
176}
177
178fn transition_deposit_timeout(old_state: &DepositStateMachine) -> DepositStateMachine {
179 assert!(
180 matches!(old_state.state, DepositStates::Created(_)),
181 "Invalid previous state"
182 );
183
184 DepositStateMachine {
185 operation_id: old_state.operation_id,
186 state: DepositStates::TimedOut(TimedOutDepositState {}),
187 }
188}
189
190#[instrument(target = LOG_CLIENT_MODULE_WALLET, skip_all, level = "debug")]
191async fn await_btc_transaction_confirmed(
192 context: WalletClientContext,
193 global_context: DynGlobalClientContext,
194 waiting_state: WaitingForConfirmationsDepositState,
195) -> (TxOutProof, ModuleConsensusVersion) {
196 loop {
197 let consensus_block_count = match global_context
200 .module_api()
201 .fetch_consensus_block_count()
202 .await
203 {
204 Ok(consensus_block_count) => consensus_block_count,
205 Err(e) => {
206 warn!("Failed to fetch consensus block count from federation: {e}");
207 sleep(TRANSACTION_STATUS_FETCH_INTERVAL).await;
208 continue;
209 }
210 };
211 debug!(consensus_block_count, "Fetched consensus block count");
212
213 let confirmation_block_count = match context
214 .rpc
215 .get_tx_block_height(&waiting_state.btc_transaction.compute_txid())
216 .await
217 {
218 Ok(Some(confirmation_height)) => Some(confirmation_height + 1),
219 Ok(None) => None,
220 Err(e) => {
221 warn!("Failed to fetch confirmation height: {e:?}");
222 sleep(TRANSACTION_STATUS_FETCH_INTERVAL).await;
223 continue;
224 }
225 };
226
227 debug!(
228 ?confirmation_block_count,
229 "Fetched confirmation block count"
230 );
231
232 if !confirmation_block_count.is_some_and(|confirmation_block_count| {
233 consensus_block_count >= confirmation_block_count
234 }) {
235 trace!(
236 "Not confirmed yet, confirmation_block_count={confirmation_block_count:?}, consensus_block_count={consensus_block_count}"
237 );
238 sleep(TRANSACTION_STATUS_FETCH_INTERVAL).await;
239 continue;
240 }
241
242 let txout_proof = match context
244 .rpc
245 .get_txout_proof(waiting_state.btc_transaction.compute_txid())
246 .await
247 {
248 Ok(txout_proof) => txout_proof,
249 Err(e) => {
250 warn!("Failed to fetch transaction proof: {e:?}");
251 sleep(TRANSACTION_STATUS_FETCH_INTERVAL).await;
252 continue;
253 }
254 };
255
256 debug!(proof_block_hash = ?txout_proof.block_header.block_hash(), "Generated merkle proof");
257
258 let consensus_version = match global_context.module_api().module_consensus_version().await {
259 Ok(version) => version,
260 Err(e) => {
261 warn!("Failed to fetch module_consensus_version: {e:?}");
262 sleep(TRANSACTION_STATUS_FETCH_INTERVAL).await;
263 continue;
264 }
265 };
266
267 return (txout_proof, consensus_version);
268 }
269}
270
271pub(crate) async fn transition_btc_tx_confirmed(
272 dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
273 global_context: DynGlobalClientContext,
274 old_state: DepositStateMachine,
275 (txout_proof, consensus_version): (TxOutProof, ModuleConsensusVersion),
276) -> DepositStateMachine {
277 let DepositStates::WaitingForConfirmations(awaiting_confirmation_state) = old_state.state
278 else {
279 panic!("Invalid previous state")
280 };
281
282 let pegin_proof = PegInProof::new(
283 txout_proof,
284 awaiting_confirmation_state.btc_transaction.clone(),
285 awaiting_confirmation_state.out_idx,
286 awaiting_confirmation_state.tweak_key.public_key(),
287 )
288 .expect("TODO: handle API returning faulty proofs");
289
290 let amount = pegin_proof.tx_output().value.into();
291
292 let wallet_input = if consensus_version >= ModuleConsensusVersion::new(2, 2) {
293 WalletInput::new_v1(&pegin_proof)
294 } else {
295 WalletInput::new_v0(pegin_proof)
296 };
297
298 let client_input = ClientInput::<WalletInput> {
299 input: wallet_input,
300 keys: vec![awaiting_confirmation_state.tweak_key],
301 amount,
302 };
303
304 let change_range = global_context
305 .claim_inputs(dbtx, ClientInputBundle::new_no_sm(vec![client_input]))
306 .await
307 .expect("Cannot claim input, additional funding needed");
308
309 DepositStateMachine {
310 operation_id: old_state.operation_id,
311 state: DepositStates::Claiming(ClaimingDepositState {
312 transaction_id: change_range.txid(),
313 change: change_range.into_iter().collect(),
314 }),
315 }
316}
317
318#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
319pub enum DepositStates {
320 Created(CreatedDepositState),
321 WaitingForConfirmations(WaitingForConfirmationsDepositState),
322 Claiming(ClaimingDepositState),
323 TimedOut(TimedOutDepositState),
324}
325
326#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
327pub struct CreatedDepositState {
328 pub(crate) tweak_key: Keypair,
329 pub(crate) timeout_at: SystemTime,
330}
331
332#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
333pub struct WaitingForConfirmationsDepositState {
334 tweak_key: Keypair,
338 pub(crate) btc_transaction: bitcoin::Transaction,
341 pub(crate) out_idx: u32,
343}
344
345#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
346pub struct ClaimingDepositState {
347 pub(crate) transaction_id: TransactionId,
349 pub(crate) change: Vec<OutPoint>,
350}
351
352#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
353pub struct TimedOutDepositState {}