Skip to main content

miden_multisig_client/
builder.rs

1//! Builder pattern for constructing MultisigClient instances.
2
3use std::path::PathBuf;
4use std::sync::Arc;
5
6use miden_client::DebugMode;
7use miden_client::builder::ClientBuilder;
8use miden_client::grpc_support::{
9    DEFAULT_GRPC_TIMEOUT_MS, DEVNET_PROVER_ENDPOINT, TESTNET_PROVER_ENDPOINT,
10};
11use miden_client::keystore::FilesystemKeyStore;
12use miden_client::note_transport::grpc::GrpcNoteTransportClient;
13use miden_client::note_transport::{
14    NOTE_TRANSPORT_DEVNET_ENDPOINT, NOTE_TRANSPORT_TESTNET_ENDPOINT, NoteTransportClient,
15};
16use miden_client::rpc::{Endpoint, GrpcClient, NodeRpcClient};
17use miden_client_sqlite_store::SqliteStore;
18use miden_protocol::crypto::dsa::ecdsa_k256_keccak::SigningKey as EcdsaSecretKey;
19use miden_protocol::crypto::dsa::falcon512_poseidon2::SecretKey;
20use miden_protocol::crypto::rand::RandomCoin;
21
22use crate::MidenSdkClient;
23use crate::client::MultisigClient;
24use crate::error::{MultisigError, Result};
25use crate::keystore::{EcdsaGuardianKeyStore, GuardianKeyStore, KeyManager};
26use crate::prover::{ProverConfig, ProverSelection, RetryingTransactionProver};
27use crate::rpc::{
28    RetryingNodeRpcClient, RpcConfig, RpcSelection, configured_note_transport_client,
29};
30
31/// Always constructed with the inner miden-client retry loop disabled:
32/// that loop retransmits rate-limited submissions (`is_retryable` covers
33/// `ResourceExhausted`/`Unavailable`), which violates at-most-once
34/// submission. [`RetryingNodeRpcClient`] is the only retry layer, and it
35/// never retries submissions.
36///
37/// The default per-request deadline is the miden-client 10s default on
38/// every path — preset, custom endpoint, and direct commitment reads.
39pub(crate) fn configured_node_rpc_client(
40    endpoint: &Endpoint,
41    rpc_config: &RpcConfig,
42) -> Arc<dyn NodeRpcClient> {
43    let (timeout_ms, retry_policy) = match rpc_config.resolve(DEFAULT_GRPC_TIMEOUT_MS) {
44        RpcSelection::Passthrough => (DEFAULT_GRPC_TIMEOUT_MS, None),
45        RpcSelection::Configured {
46            timeout_ms,
47            retry_policy,
48        } => (timeout_ms, Some(retry_policy)),
49    };
50    let grpc: Arc<dyn NodeRpcClient> =
51        Arc::new(GrpcClient::new(endpoint, timeout_ms).with_max_retries(0));
52    match retry_policy {
53        Some(policy) if policy.max_attempts() > 1 => {
54            Arc::new(RetryingNodeRpcClient::new(grpc, &policy))
55        }
56        _ => grpc,
57    }
58}
59
60fn preset_note_transport_endpoint(endpoint: &Endpoint) -> Option<&'static str> {
61    if endpoint == &Endpoint::testnet() {
62        Some(NOTE_TRANSPORT_TESTNET_ENDPOINT)
63    } else if endpoint == &Endpoint::devnet() {
64        Some(NOTE_TRANSPORT_DEVNET_ENDPOINT)
65    } else {
66        None
67    }
68}
69
70/// An explicit endpoint always wires the transport; the preset endpoint is
71/// wired only when the RPC config is customized, so a passthrough build
72/// keeps the upstream miden-client transport defaults.
73fn resolved_note_transport_endpoint(
74    endpoint: &Endpoint,
75    note_transport_endpoint: Option<&str>,
76    selection: &RpcSelection,
77) -> Option<String> {
78    if let Some(url) = note_transport_endpoint {
79        return Some(url.to_string());
80    }
81    match selection {
82        RpcSelection::Passthrough => None,
83        RpcSelection::Configured { .. } => {
84            preset_note_transport_endpoint(endpoint).map(str::to_string)
85        }
86    }
87}
88
89fn configured_client_builder(
90    endpoint: &Endpoint,
91    note_transport_endpoint: Option<&str>,
92    prover_config: &ProverConfig,
93    rpc_config: &RpcConfig,
94) -> ClientBuilder<FilesystemKeyStore> {
95    let base = if endpoint == &Endpoint::devnet() {
96        ClientBuilder::<FilesystemKeyStore>::for_devnet()
97    } else if endpoint == &Endpoint::testnet() {
98        ClientBuilder::<FilesystemKeyStore>::for_testnet()
99    } else if endpoint == &Endpoint::localhost() {
100        ClientBuilder::<FilesystemKeyStore>::for_localhost()
101    } else {
102        ClientBuilder::<FilesystemKeyStore>::new()
103    };
104
105    let builder = base.rpc(configured_node_rpc_client(endpoint, rpc_config));
106
107    let selection = rpc_config.resolve(DEFAULT_GRPC_TIMEOUT_MS);
108    let builder =
109        match resolved_note_transport_endpoint(endpoint, note_transport_endpoint, &selection) {
110            Some(transport_endpoint) => {
111                let timeout_ms = match &selection {
112                    RpcSelection::Passthrough => DEFAULT_GRPC_TIMEOUT_MS,
113                    RpcSelection::Configured { timeout_ms, .. } => *timeout_ms,
114                };
115                let transport: Arc<dyn NoteTransportClient> =
116                    Arc::new(GrpcNoteTransportClient::new(transport_endpoint, timeout_ms));
117                builder.note_transport(configured_note_transport_client(transport, rpc_config))
118            }
119            None => builder,
120        };
121
122    let default_remote = if endpoint == &Endpoint::devnet() {
123        Some(DEVNET_PROVER_ENDPOINT)
124    } else if endpoint == &Endpoint::testnet() {
125        Some(TESTNET_PROVER_ENDPOINT)
126    } else {
127        None
128    };
129
130    match prover_config.resolve(default_remote) {
131        ProverSelection::Local => builder,
132        ProverSelection::Remote {
133            endpoint,
134            custom: _,
135            retry_policy,
136        } => builder.prover(Arc::new(RetryingTransactionProver::remote(
137            endpoint,
138            retry_policy,
139        ))),
140    }
141}
142
143/// Builder for constructing MultisigClient instances.
144///
145/// # Example
146///
147/// ```ignore
148/// use miden_multisig_client::MultisigClient;
149/// use miden_client::rpc::Endpoint;
150///
151/// let client = MultisigClient::builder()
152///     .miden_endpoint(Endpoint::new("http://localhost:57291"))
153///     .guardian_endpoint("http://localhost:50051")
154///     .account_dir("/tmp/multisig-client")
155///     .prover_config(
156///         miden_multisig_client::ProverConfig::new()
157///             .with_url("https://prover.example")?
158///             .with_retry_policy(miden_multisig_client::ProverRetryPolicy::new(4)),
159///     )
160///     .generate_key()
161///     .build()
162///     .await?;
163/// ```
164pub struct MultisigClientBuilder {
165    miden_endpoint: Option<Endpoint>,
166    note_transport_endpoint: Option<String>,
167    guardian_endpoint: Option<String>,
168    account_dir: Option<PathBuf>,
169    key_manager: Option<Arc<dyn KeyManager>>,
170    prover_config: ProverConfig,
171    rpc_config: RpcConfig,
172}
173
174impl Default for MultisigClientBuilder {
175    fn default() -> Self {
176        Self::new()
177    }
178}
179
180impl MultisigClientBuilder {
181    /// Creates a new builder with default settings.
182    pub fn new() -> Self {
183        Self {
184            miden_endpoint: None,
185            note_transport_endpoint: None,
186            guardian_endpoint: None,
187            account_dir: None,
188            key_manager: None,
189            prover_config: ProverConfig::new(),
190            rpc_config: RpcConfig::new(),
191        }
192    }
193
194    /// Sets the Miden node RPC endpoint.
195    pub fn miden_endpoint(mut self, endpoint: Endpoint) -> Self {
196        self.miden_endpoint = Some(endpoint);
197        self
198    }
199
200    /// Sets the note transport service endpoint used for private note relay.
201    ///
202    /// Overrides the default derived from the Miden endpoint (the public
203    /// transport services for the testnet and devnet presets). A custom
204    /// Miden node endpoint has no derivable transport service, so this is
205    /// the only way to enable note transport there.
206    pub fn note_transport_endpoint(mut self, endpoint: impl Into<String>) -> Self {
207        self.note_transport_endpoint = Some(endpoint.into());
208        self
209    }
210
211    /// Sets the GUARDIAN server endpoint.
212    pub fn guardian_endpoint(mut self, endpoint: impl Into<String>) -> Self {
213        self.guardian_endpoint = Some(endpoint.into());
214        self
215    }
216
217    /// Configures the remote transaction prover and its retry policy.
218    pub fn prover_config(mut self, prover_config: ProverConfig) -> Self {
219        self.prover_config = prover_config;
220        self
221    }
222
223    /// Configures the Miden node RPC timeout and idempotent-read retry policy.
224    pub fn rpc_config(mut self, rpc_config: RpcConfig) -> Self {
225        self.rpc_config = rpc_config;
226        self
227    }
228
229    /// Sets the account directory for miden-client storage.
230    ///
231    /// This directory will contain the SQLite database for account and transaction data.
232    pub fn account_dir(mut self, path: impl Into<PathBuf>) -> Self {
233        self.account_dir = Some(path.into());
234        self
235    }
236
237    /// Sets a custom key manager for GUARDIAN authentication and proposal signing.
238    pub fn key_manager(mut self, key_manager: Box<dyn KeyManager>) -> Self {
239        self.key_manager = Some(key_manager.into());
240        self
241    }
242
243    /// Uses a FalconKeyStore with the given secret key.
244    pub fn with_secret_key(mut self, secret_key: SecretKey) -> Self {
245        self.key_manager = Some(Arc::new(GuardianKeyStore::new(secret_key)));
246        self
247    }
248
249    /// Uses an ECDSA key store with the given secret key.
250    pub fn with_ecdsa_secret_key(mut self, secret_key: EcdsaSecretKey) -> Self {
251        self.key_manager = Some(Arc::new(EcdsaGuardianKeyStore::new(secret_key)));
252        self
253    }
254
255    /// Generates a new random key for GUARDIAN authentication.
256    pub fn generate_key(mut self) -> Self {
257        self.key_manager = Some(Arc::new(GuardianKeyStore::generate()));
258        self
259    }
260
261    /// Generates a new random ECDSA key for GUARDIAN authentication.
262    pub fn generate_ecdsa_key(mut self) -> Self {
263        self.key_manager = Some(Arc::new(EcdsaGuardianKeyStore::generate()));
264        self
265    }
266
267    /// Builds the MultisigClient.
268    pub async fn build(self) -> Result<MultisigClient> {
269        let miden_endpoint = self
270            .miden_endpoint
271            .ok_or_else(|| MultisigError::MissingConfig("miden_endpoint".to_string()))?;
272
273        let note_transport_endpoint = match self.note_transport_endpoint {
274            Some(endpoint) => {
275                let trimmed = endpoint.trim();
276                if trimmed.is_empty() {
277                    return Err(MultisigError::InvalidConfig(
278                        "note_transport_endpoint must not be empty".to_string(),
279                    ));
280                }
281                Some(trimmed.to_string())
282            }
283            None => None,
284        };
285
286        let guardian_endpoint = self
287            .guardian_endpoint
288            .ok_or_else(|| MultisigError::MissingConfig("guardian_endpoint".to_string()))?;
289
290        let account_dir = self
291            .account_dir
292            .ok_or_else(|| MultisigError::MissingConfig("account_dir".to_string()))?;
293
294        let key_manager = self.key_manager.ok_or(MultisigError::NoSigner)?;
295
296        // Ensure account directory exists
297        std::fs::create_dir_all(&account_dir).map_err(|e| {
298            MultisigError::MidenClient(format!("failed to create account dir: {}", e))
299        })?;
300
301        let miden_client = create_miden_client(
302            &account_dir,
303            &miden_endpoint,
304            note_transport_endpoint.as_deref(),
305            &self.prover_config,
306            &self.rpc_config,
307        )
308        .await?;
309
310        Ok(MultisigClient::new(
311            miden_client,
312            key_manager,
313            guardian_endpoint,
314            account_dir,
315            miden_endpoint,
316            note_transport_endpoint,
317            self.prover_config,
318            self.rpc_config,
319        ))
320    }
321}
322
323/// Creates a miden-client instance with SQLite storage.
324///
325/// Each call creates a fresh database with a unique filename to ensure
326/// no accumulated state from previous sessions.
327pub(crate) async fn create_miden_client(
328    account_dir: &std::path::Path,
329    endpoint: &Endpoint,
330    note_transport_endpoint: Option<&str>,
331    prover_config: &ProverConfig,
332    rpc_config: &RpcConfig,
333) -> Result<MidenSdkClient> {
334    let timestamp = std::time::SystemTime::now()
335        .duration_since(std::time::UNIX_EPOCH)
336        .unwrap_or_default()
337        .as_millis();
338    let random_suffix: u32 = rand::random();
339    let store_path = account_dir.join(format!(
340        "miden-client-{}-{}.sqlite",
341        timestamp, random_suffix
342    ));
343    let store = SqliteStore::new(store_path)
344        .await
345        .map_err(|e| MultisigError::MidenClient(format!("failed to open SQLite store: {}", e)))?;
346    let store = Arc::new(store);
347
348    let rng_seed: [u32; 4] = rand::random();
349    let rng = Box::new(RandomCoin::new(rng_seed.into()));
350
351    configured_client_builder(endpoint, note_transport_endpoint, prover_config, rpc_config)
352        .store(store)
353        .rng(rng)
354        .in_debug_mode(DebugMode::Enabled)
355        .tx_discard_delta(Some(20))
356        .max_block_number_delta(256)
357        .build()
358        .await
359        .map_err(|e| MultisigError::MidenClient(format!("failed to create miden client: {}", e)))
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use crate::rpc::RpcRetryPolicy;
366
367    fn custom_endpoint() -> Endpoint {
368        Endpoint::new("http".to_string(), "node".to_string(), Some(57291))
369    }
370
371    fn single_attempt_config() -> RpcConfig {
372        RpcConfig::new().with_retry_policy(RpcRetryPolicy::new(1))
373    }
374
375    fn selection(rpc_config: &RpcConfig) -> RpcSelection {
376        rpc_config.resolve(DEFAULT_GRPC_TIMEOUT_MS)
377    }
378
379    #[test]
380    fn note_transport_endpoints_exist_only_for_public_presets() {
381        assert_eq!(
382            preset_note_transport_endpoint(&Endpoint::testnet()),
383            Some(NOTE_TRANSPORT_TESTNET_ENDPOINT)
384        );
385        assert_eq!(
386            preset_note_transport_endpoint(&Endpoint::devnet()),
387            Some(NOTE_TRANSPORT_DEVNET_ENDPOINT)
388        );
389        assert_eq!(preset_note_transport_endpoint(&Endpoint::localhost()), None);
390        assert_eq!(preset_note_transport_endpoint(&custom_endpoint()), None);
391    }
392
393    #[test]
394    fn explicit_note_transport_endpoint_wires_regardless_of_rpc_config() {
395        assert_eq!(
396            resolved_note_transport_endpoint(
397                &custom_endpoint(),
398                Some("https://transport.internal"),
399                &selection(&single_attempt_config()),
400            ),
401            Some("https://transport.internal".to_string())
402        );
403        assert_eq!(
404            resolved_note_transport_endpoint(
405                &Endpoint::testnet(),
406                Some("https://transport.internal"),
407                &selection(&RpcConfig::new()),
408            ),
409            Some("https://transport.internal".to_string())
410        );
411    }
412
413    #[test]
414    fn preset_note_transport_wires_unless_rpc_config_is_passthrough() {
415        assert_eq!(
416            resolved_note_transport_endpoint(
417                &Endpoint::testnet(),
418                None,
419                &selection(&RpcConfig::new())
420            ),
421            Some(NOTE_TRANSPORT_TESTNET_ENDPOINT.to_string())
422        );
423        assert_eq!(
424            resolved_note_transport_endpoint(
425                &Endpoint::testnet(),
426                None,
427                &selection(&single_attempt_config())
428            ),
429            None
430        );
431        let timeout_only = single_attempt_config().with_timeout_ms(5_000).unwrap();
432        assert_eq!(
433            resolved_note_transport_endpoint(&Endpoint::testnet(), None, &selection(&timeout_only)),
434            Some(NOTE_TRANSPORT_TESTNET_ENDPOINT.to_string())
435        );
436    }
437
438    #[test]
439    fn custom_endpoint_without_override_has_no_note_transport() {
440        assert_eq!(
441            resolved_note_transport_endpoint(
442                &custom_endpoint(),
443                None,
444                &selection(&RpcConfig::new())
445            ),
446            None
447        );
448    }
449
450    /// Guards the `with_max_retries(0)` carve-out over a real gRPC wire: the
451    /// miden-client internal transport-retry loop retransmits rate-limited
452    /// requests — including submissions — up to four extra times per attempt
453    /// when enabled, so two wrapper attempts must reach the node as exactly
454    /// two requests.
455    #[tokio::test]
456    async fn the_inner_miden_client_retry_loop_stays_disabled_over_a_real_wire() {
457        use std::sync::atomic::{AtomicU32, Ordering};
458
459        let calls = Arc::new(AtomicU32::new(0));
460        let node = miden_rpc_client::test_node::ScriptedNode::failing(
461            u32::MAX,
462            || tonic::Status::resource_exhausted("Too Many Requests!"),
463            calls.clone(),
464        );
465        let url = miden_rpc_client::test_node::serve(node).await;
466        let address = url
467            .strip_prefix("http://")
468            .expect("scripted node is plain http");
469        let (host, port) = address.rsplit_once(':').expect("endpoint carries a port");
470        let endpoint = Endpoint::new(
471            "http".to_string(),
472            host.to_string(),
473            Some(port.parse().expect("ephemeral port parses")),
474        );
475
476        let rpc_config = RpcConfig::new().with_retry_policy(RpcRetryPolicy::new(2));
477        let client = configured_node_rpc_client(&endpoint, &rpc_config);
478
479        client
480            .get_rpc_limits()
481            .await
482            .expect_err("the scripted node always rate-limits");
483
484        assert_eq!(calls.load(Ordering::SeqCst), 2);
485    }
486}