fedimint_client_module/transaction/
sm.rs1use std::sync::atomic::{AtomicU64, Ordering};
4use std::time::Duration;
5
6use fedimint_core::TransactionId;
7use fedimint_core::core::{Decoder, IntoDynInstance, ModuleInstanceId, ModuleKind, OperationId};
8use fedimint_core::encoding::{Decodable, DecodeError, Encodable};
9use fedimint_core::time::duration_since_epoch;
10use fedimint_core::transaction::{Transaction, TransactionSubmissionOutcome};
11use fedimint_core::util::backoff_util::custom_backoff;
12use fedimint_core::util::retry;
13use fedimint_logging::LOG_CLIENT_NET_API;
14use tokio::sync::watch;
15use tracing::{debug, warn};
16
17use crate::sm::{Context, DynContext, State, StateTransition};
18use crate::{
19 DynGlobalClientContext, DynState, TxAcceptedEvent, TxRejectedEvent, TxSubmissionStalledEvent,
20};
21
22pub const TRANSACTION_SUBMISSION_MODULE_INSTANCE: ModuleInstanceId = 0xffff;
25
26const SUBMISSION_STALL_WARN_AFTER: Duration = Duration::from_mins(30);
33
34const SUBMISSION_STALL_WARN_INTERVAL: Duration = Duration::from_mins(30);
36
37#[derive(Debug, Clone)]
38pub struct TxSubmissionContext;
39
40impl Context for TxSubmissionContext {
41 const KIND: Option<ModuleKind> = None;
42}
43
44impl IntoDynInstance for TxSubmissionContext {
45 type DynType = DynContext;
46
47 fn into_dyn(self, instance_id: ModuleInstanceId) -> Self::DynType {
48 DynContext::from_typed(instance_id, self)
49 }
50}
51
52#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
63pub struct TxSubmissionStatesSM {
64 pub operation_id: OperationId,
65 pub state: TxSubmissionStates,
66}
67
68#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
69pub enum TxSubmissionStates {
70 Created(Transaction),
73 Accepted(TransactionId),
77 Rejected(TransactionId, String),
81 NonRetryableError(String),
86}
87
88impl State for TxSubmissionStatesSM {
89 type ModuleContext = TxSubmissionContext;
90
91 fn transitions(
92 &self,
93 _context: &Self::ModuleContext,
94 global_context: &DynGlobalClientContext,
95 ) -> Vec<StateTransition<Self>> {
96 let operation_id = self.operation_id;
97 let (tx_submitted_sender, tx_submitted_receiver) = watch::channel(false);
105 match self.state.clone() {
106 TxSubmissionStates::Created(transaction) => {
107 let txid = transaction.tx_hash();
108 vec![
109 StateTransition::new(
110 TxSubmissionStates::trigger_created_rejected(
111 transaction.clone(),
112 global_context.clone(),
113 tx_submitted_sender,
114 operation_id,
115 ),
116 {
117 let global_context = global_context.clone();
118 move |sm_dbtx, error, _| {
119 let global_context = global_context.clone();
120 Box::pin(async move {
121 global_context
122 .log_event(
123 sm_dbtx,
124 TxRejectedEvent {
125 txid,
126 operation_id,
127 error: error.clone(),
128 },
129 )
130 .await;
131 TxSubmissionStatesSM {
132 state: TxSubmissionStates::Rejected(txid, error),
133 operation_id,
134 }
135 })
136 }
137 },
138 ),
139 StateTransition::new(
140 TxSubmissionStates::trigger_created_accepted(
141 txid,
142 global_context.clone(),
143 tx_submitted_receiver,
144 ),
145 {
146 let global_context = global_context.clone();
147 move |sm_dbtx, (), _| {
148 let global_context = global_context.clone();
149 Box::pin(async move {
150 global_context
151 .log_event(sm_dbtx, TxAcceptedEvent { txid, operation_id })
152 .await;
153 TxSubmissionStatesSM {
154 state: TxSubmissionStates::Accepted(txid),
155 operation_id,
156 }
157 })
158 }
159 },
160 ),
161 ]
162 }
163 TxSubmissionStates::Accepted(..)
164 | TxSubmissionStates::Rejected(..)
165 | TxSubmissionStates::NonRetryableError(..) => {
166 vec![]
167 }
168 }
169 }
170
171 fn operation_id(&self) -> OperationId {
172 self.operation_id
173 }
174
175 fn fmt_visualization(&self, f: &mut dyn std::fmt::Write, indent: &str) -> std::fmt::Result {
176 match &self.state {
177 TxSubmissionStates::Created(tx) => {
178 let txid = tx.tx_hash();
179 write!(
180 f,
181 "{indent}TxSubmissionStatesSM\n{indent} state: Created txid={} inputs={} outputs={}",
182 txid.fmt_short(),
183 tx.inputs.len(),
184 tx.outputs.len(),
185 )
186 }
187 TxSubmissionStates::Accepted(txid) => {
188 write!(
189 f,
190 "{indent}TxSubmissionStatesSM\n{indent} state: Accepted txid={}",
191 txid.fmt_short(),
192 )
193 }
194 TxSubmissionStates::Rejected(txid, err) => {
195 write!(
196 f,
197 "{indent}TxSubmissionStatesSM\n{indent} state: Rejected txid={} error={err}",
198 txid.fmt_short(),
199 )
200 }
201 TxSubmissionStates::NonRetryableError(err) => {
202 write!(
203 f,
204 "{indent}TxSubmissionStatesSM\n{indent} state: NonRetryableError error={err}",
205 )
206 }
207 }
208 }
209}
210
211impl TxSubmissionStates {
212 async fn trigger_created_rejected(
213 transaction: Transaction,
214 context: DynGlobalClientContext,
215 tx_submitted: watch::Sender<bool>,
216 operation_id: OperationId,
217 ) -> String {
218 let txid = transaction.tx_hash();
219 debug!(target: LOG_CLIENT_NET_API, %txid, "Submitting transaction");
220
221 let started_s = duration_since_epoch().as_secs();
222 let attempts = AtomicU64::new(0);
223 let next_alert_deadline_s =
227 AtomicU64::new(started_s + SUBMISSION_STALL_WARN_AFTER.as_secs());
228
229 retry(
230 "tx-submit-sm",
231 custom_backoff(Duration::from_secs(2), Duration::from_mins(10), None),
232 || async {
233 let attempt = attempts.fetch_add(1, Ordering::Relaxed).saturating_add(1);
234 if let TransactionSubmissionOutcome(Err(transaction_error)) = context
235 .api()
236 .submit_transaction(transaction.clone())
237 .await
238 .try_into_inner(context.decoders())?
239 {
240 Ok(transaction_error.to_string())
241 } else {
242 debug!(
243 target: LOG_CLIENT_NET_API,
244 %txid,
245 "Transaction submission accepted by peer, awaiting consensus",
246 );
247 tx_submitted.send_replace(true);
248
249 let now_s = duration_since_epoch().as_secs();
259 if next_alert_deadline_s.load(Ordering::Relaxed) <= now_s {
260 next_alert_deadline_s.store(
261 now_s + SUBMISSION_STALL_WARN_INTERVAL.as_secs(),
262 Ordering::Relaxed,
263 );
264 let elapsed_s = now_s.saturating_sub(started_s);
265 warn!(
266 target: LOG_CLIENT_NET_API,
267 %txid,
268 operation_id = %operation_id.fmt_short(),
269 %attempt,
270 %elapsed_s,
271 "Transaction neither accepted nor rejected; still re-submitting",
272 );
273 context
277 .log_event_no_dbtx(TxSubmissionStalledEvent {
278 txid,
279 operation_id,
280 attempt,
281 elapsed_s,
282 })
283 .await;
284 }
285
286 Err(SubmitAttemptError::StillValid)
287 }
288 },
289 )
290 .await
291 .expect("Number of retries is has no limit")
292 }
293
294 async fn trigger_created_accepted(
295 txid: TransactionId,
296 context: DynGlobalClientContext,
297 mut tx_submitted: watch::Receiver<bool>,
298 ) {
299 let _ = tx_submitted.wait_for(|submitted| *submitted).await;
300 context.api().await_transaction(txid).await;
301 debug!(target: LOG_CLIENT_NET_API, %txid, "Transaction accepted in consensus");
302 }
303}
304
305#[derive(Debug, thiserror::Error)]
308enum SubmitAttemptError {
309 #[error(transparent)]
311 Decode(#[from] DecodeError),
312
313 #[error("Transaction is still valid")]
316 StillValid,
317}
318
319impl IntoDynInstance for TxSubmissionStatesSM {
320 type DynType = DynState;
321
322 fn into_dyn(self, instance_id: ModuleInstanceId) -> Self::DynType {
323 DynState::from_typed(instance_id, self)
324 }
325}
326
327pub fn tx_submission_sm_decoder() -> Decoder {
328 let mut decoder_builder = Decoder::builder_system();
329 decoder_builder.with_decodable_type::<TxSubmissionStatesSM>();
330 decoder_builder.build()
331}