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, 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#[cfg_attr(doc, aquamarine::aquamarine)]
53#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
64pub struct TxSubmissionStatesSM {
65 pub operation_id: OperationId,
66 pub state: TxSubmissionStates,
67}
68
69#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
70pub enum TxSubmissionStates {
71 Created(Transaction),
74 Accepted(TransactionId),
78 Rejected(TransactionId, String),
82 NonRetryableError(String),
87}
88
89impl State for TxSubmissionStatesSM {
90 type ModuleContext = TxSubmissionContext;
91
92 fn transitions(
93 &self,
94 _context: &Self::ModuleContext,
95 global_context: &DynGlobalClientContext,
96 ) -> Vec<StateTransition<Self>> {
97 let operation_id = self.operation_id;
98 let (tx_submitted_sender, tx_submitted_receiver) = watch::channel(false);
106 match self.state.clone() {
107 TxSubmissionStates::Created(transaction) => {
108 let txid = transaction.tx_hash();
109 vec![
110 StateTransition::new(
111 TxSubmissionStates::trigger_created_rejected(
112 transaction.clone(),
113 global_context.clone(),
114 tx_submitted_sender,
115 operation_id,
116 ),
117 {
118 let global_context = global_context.clone();
119 move |sm_dbtx, error, _| {
120 let global_context = global_context.clone();
121 Box::pin(async move {
122 global_context
123 .log_event(
124 sm_dbtx,
125 TxRejectedEvent {
126 txid,
127 operation_id,
128 error: error.clone(),
129 },
130 )
131 .await;
132 TxSubmissionStatesSM {
133 state: TxSubmissionStates::Rejected(txid, error),
134 operation_id,
135 }
136 })
137 }
138 },
139 ),
140 StateTransition::new(
141 TxSubmissionStates::trigger_created_accepted(
142 txid,
143 global_context.clone(),
144 tx_submitted_receiver,
145 ),
146 {
147 let global_context = global_context.clone();
148 move |sm_dbtx, (), _| {
149 let global_context = global_context.clone();
150 Box::pin(async move {
151 global_context
152 .log_event(sm_dbtx, TxAcceptedEvent { txid, operation_id })
153 .await;
154 TxSubmissionStatesSM {
155 state: TxSubmissionStates::Accepted(txid),
156 operation_id,
157 }
158 })
159 }
160 },
161 ),
162 ]
163 }
164 TxSubmissionStates::Accepted(..)
165 | TxSubmissionStates::Rejected(..)
166 | TxSubmissionStates::NonRetryableError(..) => {
167 vec![]
168 }
169 }
170 }
171
172 fn operation_id(&self) -> OperationId {
173 self.operation_id
174 }
175
176 fn fmt_visualization(&self, f: &mut dyn std::fmt::Write, indent: &str) -> std::fmt::Result {
177 match &self.state {
178 TxSubmissionStates::Created(tx) => {
179 let txid = tx.tx_hash();
180 write!(
181 f,
182 "{indent}TxSubmissionStatesSM\n{indent} state: Created txid={} inputs={} outputs={}",
183 txid.fmt_short(),
184 tx.inputs.len(),
185 tx.outputs.len(),
186 )
187 }
188 TxSubmissionStates::Accepted(txid) => {
189 write!(
190 f,
191 "{indent}TxSubmissionStatesSM\n{indent} state: Accepted txid={}",
192 txid.fmt_short(),
193 )
194 }
195 TxSubmissionStates::Rejected(txid, err) => {
196 write!(
197 f,
198 "{indent}TxSubmissionStatesSM\n{indent} state: Rejected txid={} error={err}",
199 txid.fmt_short(),
200 )
201 }
202 TxSubmissionStates::NonRetryableError(err) => {
203 write!(
204 f,
205 "{indent}TxSubmissionStatesSM\n{indent} state: NonRetryableError error={err}",
206 )
207 }
208 }
209 }
210}
211
212impl TxSubmissionStates {
213 async fn trigger_created_rejected(
214 transaction: Transaction,
215 context: DynGlobalClientContext,
216 tx_submitted: watch::Sender<bool>,
217 operation_id: OperationId,
218 ) -> String {
219 let txid = transaction.tx_hash();
220 debug!(target: LOG_CLIENT_NET_API, %txid, "Submitting transaction");
221
222 let started_s = duration_since_epoch().as_secs();
223 let attempts = AtomicU64::new(0);
224 let next_alert_deadline_s =
228 AtomicU64::new(started_s + SUBMISSION_STALL_WARN_AFTER.as_secs());
229
230 retry(
231 "tx-submit-sm",
232 custom_backoff(Duration::from_secs(2), Duration::from_mins(10), None),
233 || async {
234 let attempt = attempts.fetch_add(1, Ordering::Relaxed).saturating_add(1);
235 if let TransactionSubmissionOutcome(Err(transaction_error)) = context
236 .api()
237 .submit_transaction(transaction.clone())
238 .await
239 .try_into_inner(context.decoders())?
240 {
241 Ok(transaction_error.to_string())
242 } else {
243 debug!(
244 target: LOG_CLIENT_NET_API,
245 %txid,
246 "Transaction submission accepted by peer, awaiting consensus",
247 );
248 tx_submitted.send_replace(true);
249
250 let now_s = duration_since_epoch().as_secs();
260 if next_alert_deadline_s.load(Ordering::Relaxed) <= now_s {
261 next_alert_deadline_s.store(
262 now_s + SUBMISSION_STALL_WARN_INTERVAL.as_secs(),
263 Ordering::Relaxed,
264 );
265 let elapsed_s = now_s.saturating_sub(started_s);
266 warn!(
267 target: LOG_CLIENT_NET_API,
268 %txid,
269 operation_id = %operation_id.fmt_short(),
270 %attempt,
271 %elapsed_s,
272 "Transaction neither accepted nor rejected; still re-submitting",
273 );
274 context
278 .log_event_no_dbtx(TxSubmissionStalledEvent {
279 txid,
280 operation_id,
281 attempt,
282 elapsed_s,
283 })
284 .await;
285 }
286
287 Err(anyhow::anyhow!("Transaction is still valid"))
288 }
289 },
290 )
291 .await
292 .expect("Number of retries is has no limit")
293 }
294
295 async fn trigger_created_accepted(
296 txid: TransactionId,
297 context: DynGlobalClientContext,
298 mut tx_submitted: watch::Receiver<bool>,
299 ) {
300 let _ = tx_submitted.wait_for(|submitted| *submitted).await;
301 context.api().await_transaction(txid).await;
302 debug!(target: LOG_CLIENT_NET_API, %txid, "Transaction accepted in consensus");
303 }
304}
305
306impl IntoDynInstance for TxSubmissionStatesSM {
307 type DynType = DynState;
308
309 fn into_dyn(self, instance_id: ModuleInstanceId) -> Self::DynType {
310 DynState::from_typed(instance_id, self)
311 }
312}
313
314pub fn tx_submission_sm_decoder() -> Decoder {
315 let mut decoder_builder = Decoder::builder_system();
316 decoder_builder.with_decodable_type::<TxSubmissionStatesSM>();
317 decoder_builder.build()
318}