1use std::error::Error;
2use std::sync::Arc;
3
4use guardian_shared::retry::{
5 ProductionRetryRuntime, RetryPolicy, RetryRuntime, StructuredEvidence, grpc_code_evidence,
6 is_transient_error, run_retries,
7};
8use miden_client::RemoteTransactionProver;
9use miden_client::transaction::TransactionProver;
10use miden_protocol::transaction::{ProvenTransaction, TransactionInputs};
11use miden_tx::TransactionProverError;
12use url::Url;
13
14use crate::error::{MultisigError, Result};
15
16const DEFAULT_MAX_ATTEMPTS: u32 = 2;
17
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct ProverRetryPolicy {
20 inner: RetryPolicy,
21}
22
23impl Default for ProverRetryPolicy {
24 fn default() -> Self {
25 Self {
26 inner: RetryPolicy::new(DEFAULT_MAX_ATTEMPTS),
27 }
28 }
29}
30
31impl ProverRetryPolicy {
32 #[must_use]
33 pub fn new(max_attempts: u32) -> Self {
34 Self {
35 inner: RetryPolicy::new(max_attempts),
36 }
37 }
38
39 #[must_use]
40 pub fn max_attempts(&self) -> u32 {
41 self.inner.max_attempts()
42 }
43}
44
45#[derive(Clone, Debug, Default, PartialEq, Eq)]
46pub struct ProverConfig {
47 url: Option<Url>,
48 retry_policy: ProverRetryPolicy,
49}
50
51impl ProverConfig {
52 #[must_use]
53 pub fn new() -> Self {
54 Self::default()
55 }
56
57 pub fn with_url(mut self, url: impl AsRef<str>) -> Result<Self> {
58 self.url = Some(parse_prover_url(url.as_ref())?);
59 Ok(self)
60 }
61
62 #[must_use]
63 pub fn with_retry_policy(mut self, retry_policy: ProverRetryPolicy) -> Self {
64 self.retry_policy = retry_policy;
65 self
66 }
67
68 #[must_use]
69 pub fn url(&self) -> Option<&str> {
70 self.url.as_ref().map(Url::as_str)
71 }
72
73 #[must_use]
74 pub fn retry_policy(&self) -> &ProverRetryPolicy {
75 &self.retry_policy
76 }
77}
78
79#[derive(Clone, Debug, PartialEq, Eq)]
80pub(crate) enum ProverSelection {
81 Local,
82 Remote {
83 endpoint: String,
84 custom: bool,
85 retry_policy: ProverRetryPolicy,
86 },
87}
88
89impl ProverConfig {
90 pub(crate) fn resolve(&self, default_remote_endpoint: Option<&str>) -> ProverSelection {
91 if let Some(url) = &self.url {
92 return ProverSelection::Remote {
93 endpoint: url.as_str().to_owned(),
94 custom: true,
95 retry_policy: self.retry_policy.clone(),
96 };
97 }
98
99 match default_remote_endpoint {
100 Some(endpoint) => ProverSelection::Remote {
101 endpoint: endpoint.to_owned(),
102 custom: false,
103 retry_policy: self.retry_policy.clone(),
104 },
105 None => ProverSelection::Local,
106 }
107 }
108}
109
110fn parse_prover_url(value: &str) -> Result<Url> {
111 let trimmed = value.trim();
112 let url =
113 Url::parse(trimmed).map_err(|error| MultisigError::InvalidProverUrl(error.to_string()))?;
114
115 if !matches!(url.scheme(), "http" | "https") || url.host().is_none() {
116 return Err(MultisigError::InvalidProverUrl(
117 "must be an absolute HTTP(S) URL with a host".to_string(),
118 ));
119 }
120
121 Ok(url)
122}
123
124pub(crate) struct RetryingTransactionProver {
125 inner: Arc<dyn TransactionProver + Send + Sync>,
126 policy: ProverRetryPolicy,
127 runtime: Arc<dyn RetryRuntime>,
128}
129
130impl RetryingTransactionProver {
131 pub(crate) fn remote(endpoint: impl Into<String>, policy: ProverRetryPolicy) -> Self {
132 Self {
133 inner: Arc::new(RemoteTransactionProver::new(endpoint)),
134 policy,
135 runtime: Arc::new(ProductionRetryRuntime),
136 }
137 }
138
139 #[cfg(test)]
140 fn with_runtime(
141 inner: Arc<dyn TransactionProver + Send + Sync>,
142 policy: ProverRetryPolicy,
143 runtime: Arc<dyn RetryRuntime>,
144 ) -> Self {
145 Self {
146 inner,
147 policy,
148 runtime,
149 }
150 }
151}
152
153#[async_trait::async_trait]
154impl TransactionProver for RetryingTransactionProver {
155 async fn prove(
156 &self,
157 tx_inputs: TransactionInputs,
158 ) -> std::result::Result<ProvenTransaction, TransactionProverError> {
159 run_retries(
160 self.policy.max_attempts(),
161 self.runtime.as_ref(),
162 is_transient_prover_error,
163 |_, _| {},
164 || self.inner.prove(tx_inputs.clone()),
165 )
166 .await
167 }
168}
169
170pub(crate) fn tonic_link_evidence(cause: &(dyn Error + 'static)) -> StructuredEvidence {
171 cause
172 .downcast_ref::<tonic::Status>()
173 .map(|status| grpc_code_evidence(status.code() as i32))
174 .unwrap_or(StructuredEvidence::Indeterminate)
175}
176
177pub(crate) fn is_transient_prover_error(error: &TransactionProverError) -> bool {
178 is_transient_error(error, tonic_link_evidence)
179}
180
181#[cfg(test)]
182mod tests {
183 use std::collections::VecDeque;
184 use std::fmt;
185 use std::sync::Mutex;
186 use std::time::Duration;
187
188 use guardian_shared::retry::retry_delay;
189
190 use miden_client::testing::{Auth, MockChain};
191 use miden_protocol::account::AccountBuilder;
192 use miden_standards::account::wallets::BasicWallet;
193 use serde::Deserialize;
194
195 use super::*;
196
197 #[derive(Deserialize)]
198 #[serde(rename_all = "camelCase")]
199 struct Fixtures {
200 attempt_budgets: Vec<AttemptBudget>,
201 endpoints: Vec<EndpointFixture>,
202 classifications: Vec<ClassificationFixture>,
203 delays: Vec<DelayFixture>,
204 }
205
206 #[derive(Deserialize)]
207 struct AttemptBudget {
208 input: Option<u32>,
209 normalized: u32,
210 }
211
212 #[derive(Deserialize)]
213 struct EndpointFixture {
214 input: String,
215 valid: bool,
216 canonical: Option<String>,
217 }
218
219 #[derive(Deserialize)]
220 struct ClassificationFixture {
221 name: String,
222 chain: Vec<ErrorFixture>,
223 transient: bool,
224 }
225
226 #[derive(Deserialize)]
227 struct ErrorFixture {
228 code: Option<String>,
229 status: Option<u16>,
230 message: String,
231 }
232
233 #[derive(Deserialize)]
234 #[serde(rename_all = "camelCase")]
235 struct DelayFixture {
236 retry_index: u32,
237 unit_random: f64,
238 delay_ms: u64,
239 }
240
241 #[derive(Debug)]
242 struct FixtureError {
243 message: String,
244 source: Option<Box<FixtureError>>,
245 }
246
247 impl fmt::Display for FixtureError {
248 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
249 formatter.write_str(&self.message)
250 }
251 }
252
253 impl Error for FixtureError {
254 fn source(&self) -> Option<&(dyn Error + 'static)> {
255 self.source.as_deref().map(|source| source as _)
256 }
257 }
258
259 fn fixtures() -> Fixtures {
260 serde_json::from_str(include_str!(
261 "../../../fixtures/miden-multisig-client/prover-policy-fixtures.json"
262 ))
263 .expect("fixtures must parse")
264 }
265
266 fn fixture_error(chain: &[ErrorFixture]) -> TransactionProverError {
267 let nested = chain.iter().rev().fold(None, |source, item| {
268 let message = match (&item.code, item.status) {
269 (Some(code), _) => format!("grpc code: {code}; {}", item.message),
270 (_, Some(status)) => format!("http status {status}; {}", item.message),
271 _ => item.message.clone(),
272 };
273 Some(Box::new(FixtureError { message, source }))
274 });
275 TransactionProverError::other_with_source(
276 "fixture proving failure",
277 *nested.expect("classification chains are non-empty"),
278 )
279 }
280
281 #[test]
282 fn attempt_budget_vectors_match_contract() {
283 for fixture in fixtures().attempt_budgets {
284 let policy = fixture
285 .input
286 .map(ProverRetryPolicy::new)
287 .unwrap_or_default();
288 assert_eq!(policy.max_attempts(), fixture.normalized);
289 }
290 }
291
292 #[test]
293 fn endpoint_vectors_match_contract() {
294 for fixture in fixtures().endpoints {
295 let result = ProverConfig::new().with_url(&fixture.input);
296 assert_eq!(result.is_ok(), fixture.valid, "input: {:?}", fixture.input);
297 if let Some(canonical) = fixture.canonical {
298 assert_eq!(result.unwrap().url(), Some(canonical.as_str()));
299 }
300 }
301 }
302
303 #[test]
304 fn classification_vectors_match_contract() {
305 for fixture in fixtures().classifications {
306 let error = fixture_error(&fixture.chain);
307 assert_eq!(
308 is_transient_prover_error(&error),
309 fixture.transient,
310 "fixture: {}",
311 fixture.name
312 );
313 }
314 }
315
316 #[test]
317 fn typed_tonic_statuses_follow_whole_chain_precedence() {
318 let unavailable = TransactionProverError::other_with_source(
319 "failed to prove transaction",
320 tonic::Status::unavailable("temporarily unavailable"),
321 );
322 assert!(is_transient_prover_error(&unavailable));
323
324 let invalid = TransactionProverError::other_with_source(
325 "timeout while proving",
326 tonic::Status::invalid_argument("invalid proof"),
327 );
328 assert!(!is_transient_prover_error(&invalid));
329
330 let mixed_http =
331 TransactionProverError::other("upstream http status 408 followed by http status 400");
332 assert!(!is_transient_prover_error(&mixed_http));
333
334 let flattened_not_found =
335 TransactionProverError::other("failed to prove transaction: grpc code: NotFound");
336 assert!(!is_transient_prover_error(&flattened_not_found));
337 }
338
339 #[test]
340 fn delay_vectors_match_contract() {
341 for fixture in fixtures().delays {
342 assert_eq!(
343 retry_delay(fixture.retry_index, fixture.unit_random).as_millis(),
344 u128::from(fixture.delay_ms)
345 );
346 }
347 }
348
349 #[test]
350 fn custom_selection_overrides_local_and_default_remote() {
351 let custom = ProverConfig::new()
352 .with_url("https://prover.example")
353 .unwrap();
354 for default in [None, Some("https://tx-prover.testnet.miden.io")] {
355 assert!(matches!(
356 custom.resolve(default),
357 ProverSelection::Remote { custom: true, .. }
358 ));
359 }
360 assert_eq!(ProverConfig::new().resolve(None), ProverSelection::Local);
361 }
362
363 #[derive(Default)]
364 struct RecordingRuntime {
365 sleeps: Mutex<Vec<Duration>>,
366 }
367
368 #[async_trait::async_trait]
369 impl RetryRuntime for RecordingRuntime {
370 async fn sleep(&self, duration: Duration) {
371 self.sleeps.lock().unwrap().push(duration);
372 }
373
374 fn unit_random(&self) -> f64 {
375 0.5
376 }
377 }
378
379 struct FailingProver {
380 errors: Mutex<VecDeque<TransactionProverError>>,
381 inputs: Mutex<Vec<TransactionInputs>>,
382 }
383
384 #[async_trait::async_trait]
385 impl TransactionProver for FailingProver {
386 async fn prove(
387 &self,
388 inputs: TransactionInputs,
389 ) -> std::result::Result<ProvenTransaction, TransactionProverError> {
390 self.inputs.lock().unwrap().push(inputs);
391 Err(self
392 .errors
393 .lock()
394 .unwrap()
395 .pop_front()
396 .expect("one fixture error per expected attempt"))
397 }
398 }
399
400 fn transaction_inputs() -> TransactionInputs {
401 let mut chain = MockChain::new();
402 chain.prove_next_block().unwrap();
403 let account = AccountBuilder::new([0; 32])
404 .with_auth_component(Auth::IncrNonce)
405 .with_component(BasicWallet)
406 .build()
407 .unwrap();
408 chain.get_transaction_inputs(&account, &[], &[]).unwrap()
409 }
410
411 #[tokio::test]
412 async fn retries_the_same_inputs_and_returns_the_final_upstream_error() {
413 let inner = Arc::new(FailingProver {
414 errors: Mutex::new(VecDeque::from([
415 TransactionProverError::other("service unavailable"),
416 TransactionProverError::other("deadline exceeded: final"),
417 ])),
418 inputs: Mutex::new(Vec::new()),
419 });
420 let runtime = Arc::new(RecordingRuntime::default());
421 let prover = RetryingTransactionProver::with_runtime(
422 inner.clone(),
423 ProverRetryPolicy::new(2),
424 runtime.clone(),
425 );
426 let inputs = transaction_inputs();
427
428 let error = prover.prove(inputs.clone()).await.unwrap_err();
429
430 assert_eq!(error.to_string(), "deadline exceeded: final");
431 let recorded_inputs = inner.inputs.lock().unwrap();
432 assert_eq!(recorded_inputs.len(), 2);
433 assert_eq!(recorded_inputs[0], inputs);
434 assert_eq!(recorded_inputs[1], inputs);
435 assert_eq!(
436 runtime.sleeps.lock().unwrap().as_slice(),
437 [Duration::from_millis(500)]
438 );
439 }
440
441 #[tokio::test]
442 async fn permanent_failure_does_not_retry_or_sleep() {
443 let inner = Arc::new(FailingProver {
444 errors: Mutex::new(VecDeque::from([TransactionProverError::other(
445 "transaction kernel assertion failed",
446 )])),
447 inputs: Mutex::new(Vec::new()),
448 });
449 let runtime = Arc::new(RecordingRuntime::default());
450 let prover = RetryingTransactionProver::with_runtime(
451 inner.clone(),
452 ProverRetryPolicy::new(5),
453 runtime.clone(),
454 );
455
456 prover.prove(transaction_inputs()).await.unwrap_err();
457
458 assert_eq!(inner.inputs.lock().unwrap().len(), 1);
459 assert!(runtime.sleeps.lock().unwrap().is_empty());
460 }
461}