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