Skip to main content

miden_multisig_client/
prover.rs

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}