Skip to main content

stoffel/
server.rs

1//! Server-side builder, state, health, and metrics access.
2//!
3//! The builder validates operational configuration and captures program
4//! metadata. Starting a live server remains a lower-layer networking/VM
5//! integration concern and is not simulated by the SDK.
6
7use std::collections::BTreeSet;
8use std::ffi::OsString;
9use std::fmt;
10use std::net::{SocketAddr, ToSocketAddrs};
11use std::path::{Path, PathBuf};
12use std::process::{Child, Command, Stdio};
13use std::str::FromStr;
14use std::sync::atomic::{AtomicU8, Ordering};
15use std::sync::{Arc, Mutex};
16use std::time::Duration;
17
18use serde::{Deserialize, Serialize};
19
20use crate::backend::avss::AvssEngine;
21use crate::config::{
22    validate_socket_address, Curve, MpcBackend, MpcConfig, NetworkConfig, NetworkDeployment,
23    PreprocessingConfig,
24};
25use crate::consensus::VerifiedOrdering;
26use crate::error::{Error, Result};
27use crate::observability::{HealthStatus, ServerMetrics};
28use crate::program::Program;
29use crate::types::{GeneratedProgramManifest, PartyId};
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum ServerState {
34    Created,
35    Running,
36    Shutdown,
37}
38
39impl ServerState {
40    const fn as_u8(self) -> u8 {
41        match self {
42            ServerState::Created => 0,
43            ServerState::Running => 1,
44            ServerState::Shutdown => 2,
45        }
46    }
47
48    const fn from_u8(value: u8) -> Self {
49        match value {
50            1 => ServerState::Running,
51            2 => ServerState::Shutdown,
52            _ => ServerState::Created,
53        }
54    }
55}
56
57impl fmt::Display for ServerState {
58    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59        f.write_str(match self {
60            ServerState::Created => "created",
61            ServerState::Running => "running",
62            ServerState::Shutdown => "shutdown",
63        })
64    }
65}
66
67impl FromStr for ServerState {
68    type Err = Error;
69
70    fn from_str(value: &str) -> Result<Self> {
71        match value.trim() {
72            "created" => Ok(ServerState::Created),
73            "running" => Ok(ServerState::Running),
74            "shutdown" => Ok(ServerState::Shutdown),
75            state => Err(Error::Configuration(format!(
76                "unsupported server state '{state}'; expected 'created', 'running', or 'shutdown'"
77            ))),
78        }
79    }
80}
81
82#[derive(Debug, Clone)]
83pub struct ServerBuilder {
84    party_id: PartyId,
85    bind_addr: Option<String>,
86    peers: Vec<(usize, String)>,
87    program: Option<Program>,
88    triples: usize,
89    random_shares: usize,
90    expected_clients: usize,
91    consensus_timeout: Duration,
92    backend: MpcBackend,
93    mpc_config: Option<MpcConfig>,
94    avss_engine: Option<AvssEngine>,
95    verified_ordering: Option<VerifiedOrdering>,
96    runner_path: Option<PathBuf>,
97    bootstrap_addr: Option<String>,
98    advertise_addr: Option<SocketAddr>,
99    entry: String,
100    offchain_coordinator: Option<OffChainServerConfig>,
101    config_error: Option<String>,
102}
103
104impl ServerBuilder {
105    pub fn new(party_id: PartyId) -> Self {
106        Self {
107            party_id,
108            bind_addr: None,
109            peers: Vec::new(),
110            program: None,
111            triples: 1000,
112            random_shares: 500,
113            expected_clients: 0,
114            consensus_timeout: Duration::from_secs(60),
115            backend: MpcBackend::HoneyBadger,
116            mpc_config: None,
117            avss_engine: None,
118            verified_ordering: None,
119            runner_path: None,
120            bootstrap_addr: None,
121            advertise_addr: None,
122            entry: "main".to_owned(),
123            offchain_coordinator: None,
124            config_error: None,
125        }
126    }
127
128    pub fn bind<A: ToSocketAddrs>(mut self, addr: A) -> Self {
129        match addr.to_socket_addrs() {
130            Ok(mut addrs) => {
131                self.bind_addr = addrs.next().map(|addr| addr.to_string());
132                if self.bind_addr.is_none() {
133                    self.config_error = Some("server bind address did not resolve".to_owned());
134                }
135            }
136            Err(error) => {
137                self.config_error = Some(format!("invalid server bind address: {error}"));
138            }
139        }
140        self
141    }
142
143    pub fn peers<I, S>(mut self, peers: I) -> Self
144    where
145        I: IntoIterator<Item = (usize, S)>,
146        S: Into<String>,
147    {
148        self.peers = peers
149            .into_iter()
150            .map(|(party_id, address)| (party_id, address.into()))
151            .collect();
152        self
153    }
154
155    pub fn with_peers(self, peers: &[(usize, &str)]) -> Self {
156        self.peers(peers.iter().copied())
157    }
158
159    pub fn peer(mut self, party_id: usize, address: impl Into<String>) -> Self {
160        self.peers.push((party_id, address.into()));
161        self
162    }
163
164    pub fn with_program(mut self, program: Program) -> Self {
165        self.program = Some(program);
166        self
167    }
168
169    pub fn with_preprocessing(mut self, triples: usize, random_shares: usize) -> Self {
170        self.triples = triples;
171        self.random_shares = random_shares;
172        self
173    }
174
175    pub fn expected_clients(mut self, n: usize) -> Self {
176        self.expected_clients = n;
177        self
178    }
179
180    pub fn consensus_timeout(mut self, duration: Duration) -> Self {
181        self.consensus_timeout = duration;
182        self
183    }
184
185    pub fn backend(mut self, backend: MpcBackend) -> Self {
186        self.backend = backend;
187        if let Some(config) = &mut self.mpc_config {
188            config.backend = backend;
189        }
190        self
191    }
192
193    pub fn manifest<M: GeneratedProgramManifest>(self) -> Self {
194        self.backend(M::BACKEND)
195    }
196
197    pub fn honeybadger(self) -> Self {
198        self.backend(MpcBackend::HoneyBadger)
199    }
200
201    pub fn avss(self, curve: Curve) -> Self {
202        self.backend(MpcBackend::Avss { curve })
203    }
204
205    pub fn mpc_config(mut self, config: &MpcConfig) -> Self {
206        self.backend = config.backend;
207        self.mpc_config = Some(config.clone());
208        self
209    }
210
211    /// Attach a VM-backed AVSS engine that this server should expose.
212    ///
213    /// The builder validates that the selected backend is AVSS and that the
214    /// engine curve matches the backend curve. The SDK does not construct live
215    /// AVSS engines on its own.
216    pub fn with_avss_engine(mut self, engine: AvssEngine) -> Self {
217        self.avss_engine = Some(engine);
218        self
219    }
220
221    pub fn with_verified_ordering(mut self, ordering: VerifiedOrdering) -> Self {
222        self.verified_ordering = Some(ordering);
223        self
224    }
225
226    /// Override the `stoffel-run` binary used by [`StoffelServer::start`].
227    pub fn runner_path(mut self, path: impl AsRef<Path>) -> Self {
228        self.runner_path = Some(path.as_ref().to_path_buf());
229        self
230    }
231
232    /// Set the entrypoint passed to `stoffel-run`. Default is `main`.
233    pub fn entry(mut self, entry: impl Into<String>) -> Self {
234        self.entry = entry.into();
235        self
236    }
237
238    /// Configure this server as a follower of an existing bootnode.
239    ///
240    /// Without a bootstrap address, party 0 starts as the leader/bootnode.
241    pub fn bootstrap(mut self, address: impl Into<String>) -> Self {
242        self.bootstrap_addr = Some(address.into());
243        self
244    }
245
246    /// Set the address other parties dial to reach this party's mesh listener.
247    ///
248    /// The address is forwarded to `stoffel-run` as `--advertise` and is what
249    /// the bootnode hands to the other parties, so it must be reachable from
250    /// them. The port must be the party mesh port: for the leader (party 0
251    /// without a bootstrap address) that is the bind port + 1000, for every
252    /// other party it is the bind port itself.
253    ///
254    /// Host names are resolved once, here, preferring IPv4. When unset, the
255    /// runner advertises the address derived from the bind address, which is
256    /// only dialable by other hosts if the bind address itself is routable.
257    pub fn advertise<A: ToSocketAddrs>(mut self, addr: A) -> Self {
258        match addr.to_socket_addrs() {
259            Ok(addrs) => {
260                let addrs: Vec<SocketAddr> = addrs.collect();
261                self.advertise_addr = addrs
262                    .iter()
263                    .find(|addr| addr.is_ipv4())
264                    .or_else(|| addrs.first())
265                    .copied();
266                if self.advertise_addr.is_none() {
267                    self.config_error = Some("server advertise address did not resolve".to_owned());
268                }
269            }
270            Err(error) => {
271                self.config_error = Some(format!("invalid server advertise address: {error}"));
272            }
273        }
274        self
275    }
276
277    /// Configure the off-chain coordinator flags passed to `stoffel-run`.
278    ///
279    /// This is required when the attached program declares ClientStore IO.
280    pub fn offchain_coordinator(mut self, config: OffChainServerConfig) -> Self {
281        self.offchain_coordinator = Some(config);
282        self
283    }
284
285    pub fn configured_party_id(&self) -> PartyId {
286        self.party_id
287    }
288
289    pub fn configured_bind_addr(&self) -> Option<&str> {
290        self.bind_addr.as_deref()
291    }
292
293    pub fn configured_peers(&self) -> &[(usize, String)] {
294        &self.peers
295    }
296
297    pub fn configured_program(&self) -> Option<&Program> {
298        self.program.as_ref()
299    }
300
301    pub fn has_configured_program(&self) -> bool {
302        self.program.is_some()
303    }
304
305    pub fn configured_preprocessing(&self) -> (usize, usize) {
306        (self.triples, self.random_shares)
307    }
308
309    pub fn configured_expected_clients(&self) -> usize {
310        self.expected_clients
311    }
312
313    pub fn configured_consensus_timeout(&self) -> Duration {
314        self.consensus_timeout
315    }
316
317    pub fn configured_backend(&self) -> MpcBackend {
318        self.backend
319    }
320
321    pub fn configured_mpc_config(&self) -> Option<&MpcConfig> {
322        self.mpc_config.as_ref()
323    }
324
325    pub fn configured_avss_engine(&self) -> Option<&AvssEngine> {
326        self.avss_engine.as_ref()
327    }
328
329    pub fn has_configured_avss_engine(&self) -> bool {
330        self.avss_engine.is_some()
331    }
332
333    pub fn configured_verified_ordering(&self) -> Option<&VerifiedOrdering> {
334        self.verified_ordering.as_ref()
335    }
336
337    pub fn has_configured_verified_ordering(&self) -> bool {
338        self.verified_ordering.is_some()
339    }
340
341    pub fn configured_runner_path(&self) -> Option<&Path> {
342        self.runner_path.as_deref()
343    }
344
345    pub fn configured_entry(&self) -> &str {
346        &self.entry
347    }
348
349    pub fn configured_bootstrap(&self) -> Option<&str> {
350        self.bootstrap_addr.as_deref()
351    }
352
353    pub fn configured_advertise(&self) -> Option<SocketAddr> {
354        self.advertise_addr
355    }
356
357    pub fn configured_offchain_coordinator(&self) -> Option<&OffChainServerConfig> {
358        self.offchain_coordinator.as_ref()
359    }
360
361    pub fn network_config(mut self, config: &NetworkConfig) -> Self {
362        if let Err(error) = config.validate_server_addresses() {
363            self.config_error = Some(error.to_string());
364            return self;
365        }
366        if config.network.party_id != self.party_id {
367            self.config_error = Some(format!(
368                "network config party_id {} does not match server builder party_id {}",
369                config.network.party_id, self.party_id
370            ));
371            return self;
372        }
373        self.bind_addr = Some(config.network.bind_address.clone());
374        self.peers = config
375            .network
376            .peers
377            .iter()
378            .map(|(party_id, address)| (*party_id, address.clone()))
379            .collect();
380        self.expected_clients = config.network.expected_clients;
381        self.consensus_timeout = Duration::from_millis(config.network.consensus_timeout_ms);
382        self.triples = config.preprocessing.triples;
383        self.random_shares = config.preprocessing.random_shares;
384        let instance_id = self
385            .mpc_config
386            .as_ref()
387            .map(|config| config.instance_id)
388            .unwrap_or_default();
389        match config.to_mpc_config(instance_id) {
390            Ok(mpc_config) => {
391                self.backend = mpc_config.backend;
392                self.mpc_config = Some(mpc_config);
393            }
394            Err(error) => self.config_error = Some(error.to_string()),
395        }
396        self
397    }
398
399    /// Configure this server from the matching party config in a deployment.
400    pub fn network_deployment(self, deployment: &NetworkDeployment) -> Self {
401        match deployment.config(self.party_id) {
402            Some(config) => self.network_config(config),
403            None => {
404                let party_id = self.party_id;
405                self.configuration_error(format!(
406                    "network deployment does not contain party_id {party_id}"
407                ))
408            }
409        }
410    }
411
412    pub fn network_config_file(mut self, path: impl AsRef<Path>) -> Self {
413        match NetworkConfig::from_toml_file(path) {
414            Ok(config) => self.network_config(&config),
415            Err(error) => {
416                self.config_error = Some(error.to_string());
417                self
418            }
419        }
420    }
421
422    pub(crate) fn configuration_error(mut self, error: impl Into<String>) -> Self {
423        self.config_error = Some(error.into());
424        self
425    }
426
427    pub fn build(self) -> Result<StoffelServer> {
428        if let Some(error) = self.config_error {
429            return Err(Error::Configuration(error));
430        }
431        if self.bind_addr.is_none() {
432            return Err(Error::Configuration(
433                "server bind address is required".to_owned(),
434            ));
435        }
436        if let Some(advertise) = self.advertise_addr {
437            if advertise.ip().is_unspecified() {
438                return Err(Error::Configuration(format!(
439                    "server advertise address must be a routable address, not {advertise}"
440                )));
441            }
442            if advertise.port() == 0 {
443                return Err(Error::Configuration(format!(
444                    "server advertise address must have a non-zero port, not {advertise}"
445                )));
446            }
447        }
448        if self.consensus_timeout.is_zero() {
449            return Err(Error::Configuration(
450                "server consensus timeout must be greater than zero".to_owned(),
451            ));
452        }
453        PreprocessingConfig {
454            triples: self.triples,
455            random_shares: self.random_shares,
456        }
457        .validate()?;
458        let mut peer_ids = BTreeSet::new();
459        for (party_id, address) in &self.peers {
460            if !peer_ids.insert(*party_id) {
461                return Err(Error::Configuration(format!(
462                    "duplicate peer party_id {party_id}"
463                )));
464            }
465            if *party_id == self.party_id {
466                return Err(Error::Configuration(format!(
467                    "peer party_id {party_id} must not match this server's party_id"
468                )));
469            }
470            if address.trim().is_empty() {
471                return Err(Error::Configuration(format!(
472                    "peer address for party_id {party_id} must not be empty"
473                )));
474            }
475            validate_socket_address(&format!("peer address for party_id {party_id}"), address)?;
476        }
477        if let Some(mpc_config) = &self.mpc_config {
478            mpc_config.validate()?;
479            if self.party_id >= mpc_config.parties {
480                return Err(Error::Configuration(format!(
481                    "server party_id {} must be less than configured party count {}",
482                    self.party_id, mpc_config.parties
483                )));
484            }
485            for peer_id in &peer_ids {
486                if *peer_id >= mpc_config.parties {
487                    return Err(Error::Configuration(format!(
488                        "peer party_id {peer_id} must be less than configured party count {}",
489                        mpc_config.parties
490                    )));
491                }
492            }
493            for party_id in 0..mpc_config.parties {
494                if party_id != self.party_id && !peer_ids.contains(&party_id) {
495                    return Err(Error::Configuration(format!(
496                        "missing peer address for configured party_id {party_id}"
497                    )));
498                }
499            }
500        }
501        if let Some(program) = &self.program {
502            program.validate_expected_clients(self.expected_clients)?;
503        }
504        if let Some(config) = &self.offchain_coordinator {
505            config.validate(self.expected_clients)?;
506        }
507        if let Some(engine) = &self.avss_engine {
508            match self.backend {
509                MpcBackend::Avss { curve } if curve == engine.curve() => {}
510                MpcBackend::Avss { curve } => {
511                    return Err(Error::Configuration(format!(
512                        "configured AVSS engine curve {} does not match server backend curve {}",
513                        engine.curve(),
514                        curve
515                    )));
516                }
517                MpcBackend::HoneyBadger => {
518                    return Err(Error::Configuration(
519                        "configured AVSS engine requires MpcBackend::Avss".to_owned(),
520                    ));
521                }
522            }
523        }
524        let metrics = ServerMetrics::default();
525        metrics.record_preprocessing_remaining(self.triples as u64, self.random_shares as u64);
526        Ok(StoffelServer {
527            party_id: self.party_id,
528            bind_addr: self.bind_addr.unwrap(),
529            peers: self.peers,
530            program: self.program,
531            triples: self.triples,
532            random_shares: self.random_shares,
533            expected_clients: self.expected_clients,
534            consensus_timeout: self.consensus_timeout,
535            backend: self.backend,
536            mpc_config: self.mpc_config,
537            avss_engine: self.avss_engine,
538            state: Arc::new(AtomicU8::new(ServerState::Created.as_u8())),
539            verified_ordering: self.verified_ordering,
540            runner_path: self.runner_path,
541            bootstrap_addr: self.bootstrap_addr,
542            advertise_addr: self.advertise_addr,
543            entry: self.entry,
544            offchain_coordinator: self.offchain_coordinator,
545            process: Arc::new(Mutex::new(None)),
546            metrics,
547        })
548    }
549}
550
551#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
552pub struct OffChainServerConfig {
553    pub coordinator_address: String,
554    pub rpc_bind_address: String,
555    pub cert_path: PathBuf,
556    pub key_path: PathBuf,
557    pub timestamp: u64,
558    pub expected_client_certs: Vec<PathBuf>,
559}
560
561impl OffChainServerConfig {
562    pub fn builder() -> OffChainServerConfigBuilder {
563        OffChainServerConfigBuilder::default()
564    }
565
566    pub fn validate(&self, expected_clients: usize) -> Result<()> {
567        validate_socket_address("off-chain coordinator address", &self.coordinator_address)?;
568        validate_socket_address("off-chain node RPC bind address", &self.rpc_bind_address)?;
569        if self.timestamp == 0 {
570            return Err(Error::Configuration(
571                "off-chain coordinator timestamp must be greater than zero".to_owned(),
572            ));
573        }
574        validate_existing_file("off-chain server certificate", &self.cert_path)?;
575        validate_existing_file("off-chain server private key", &self.key_path)?;
576        for path in &self.expected_client_certs {
577            validate_existing_file("expected off-chain client certificate", path)?;
578        }
579        if expected_clients > 0 && self.expected_client_certs.len() != expected_clients {
580            return Err(Error::Configuration(format!(
581                "expected_clients is {expected_clients}, but {} expected client certificate(s) were configured",
582                self.expected_client_certs.len()
583            )));
584        }
585        Ok(())
586    }
587}
588
589#[derive(Debug, Clone, Default)]
590pub struct OffChainServerConfigBuilder {
591    coordinator_address: Option<String>,
592    rpc_bind_address: Option<String>,
593    cert_path: Option<PathBuf>,
594    key_path: Option<PathBuf>,
595    timestamp: Option<u64>,
596    expected_client_certs: Vec<PathBuf>,
597}
598
599impl OffChainServerConfigBuilder {
600    pub fn coordinator(mut self, address: impl Into<String>) -> Self {
601        self.coordinator_address = Some(address.into());
602        self
603    }
604
605    pub fn rpc_bind(mut self, address: impl Into<String>) -> Self {
606        self.rpc_bind_address = Some(address.into());
607        self
608    }
609
610    pub fn identity_files(
611        mut self,
612        cert_path: impl AsRef<Path>,
613        key_path: impl AsRef<Path>,
614    ) -> Self {
615        self.cert_path = Some(cert_path.as_ref().to_path_buf());
616        self.key_path = Some(key_path.as_ref().to_path_buf());
617        self
618    }
619
620    pub fn timestamp(mut self, timestamp: u64) -> Self {
621        self.timestamp = Some(timestamp);
622        self
623    }
624
625    pub fn expected_client_cert(mut self, path: impl AsRef<Path>) -> Self {
626        self.expected_client_certs.push(path.as_ref().to_path_buf());
627        self
628    }
629
630    pub fn expected_client_certs<I, P>(mut self, paths: I) -> Self
631    where
632        I: IntoIterator<Item = P>,
633        P: AsRef<Path>,
634    {
635        self.expected_client_certs = paths
636            .into_iter()
637            .map(|path| path.as_ref().to_path_buf())
638            .collect();
639        self
640    }
641
642    pub fn build(self) -> Result<OffChainServerConfig> {
643        let config = OffChainServerConfig {
644            coordinator_address: self.coordinator_address.ok_or_else(|| {
645                Error::Configuration("off-chain coordinator address is required".to_owned())
646            })?,
647            rpc_bind_address: self.rpc_bind_address.ok_or_else(|| {
648                Error::Configuration("off-chain node RPC bind address is required".to_owned())
649            })?,
650            cert_path: self.cert_path.ok_or_else(|| {
651                Error::Configuration("off-chain server certificate path is required".to_owned())
652            })?,
653            key_path: self.key_path.ok_or_else(|| {
654                Error::Configuration("off-chain server private key path is required".to_owned())
655            })?,
656            timestamp: self.timestamp.ok_or_else(|| {
657                Error::Configuration("off-chain coordinator timestamp is required".to_owned())
658            })?,
659            expected_client_certs: self.expected_client_certs,
660        };
661        config.validate(config.expected_client_certs.len())?;
662        Ok(config)
663    }
664}
665
666#[derive(Debug)]
667pub struct StoffelServer {
668    party_id: PartyId,
669    bind_addr: String,
670    peers: Vec<(usize, String)>,
671    program: Option<Program>,
672    triples: usize,
673    random_shares: usize,
674    expected_clients: usize,
675    consensus_timeout: Duration,
676    backend: MpcBackend,
677    mpc_config: Option<MpcConfig>,
678    avss_engine: Option<AvssEngine>,
679    state: Arc<AtomicU8>,
680    verified_ordering: Option<VerifiedOrdering>,
681    runner_path: Option<PathBuf>,
682    bootstrap_addr: Option<String>,
683    advertise_addr: Option<SocketAddr>,
684    entry: String,
685    offchain_coordinator: Option<OffChainServerConfig>,
686    process: Arc<Mutex<Option<ServerProcess>>>,
687    metrics: ServerMetrics,
688}
689
690#[derive(Debug)]
691struct ServerProcess {
692    child: Child,
693    _tempdir: tempfile::TempDir,
694}
695
696#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
697pub struct ServerSummary {
698    pub party_id: PartyId,
699    pub bind_addr: String,
700    pub peer_count: usize,
701    pub expected_clients: usize,
702    pub preprocessing_triples: usize,
703    pub preprocessing_random_shares: usize,
704    pub consensus_timeout_ms: u64,
705    pub backend: MpcBackend,
706    pub mpc_parties: Option<usize>,
707    pub mpc_threshold: Option<usize>,
708    pub mpc_instance_id: Option<u64>,
709    pub avss_engine_configured: bool,
710    pub avss_engine_live: bool,
711    pub has_verified_ordering: bool,
712    pub state: ServerState,
713    pub ready: bool,
714    pub health: HealthStatus,
715}
716
717impl StoffelServer {
718    pub fn builder(party_id: PartyId) -> ServerBuilder {
719        ServerBuilder::new(party_id)
720    }
721
722    #[tracing::instrument(skip_all, fields(party_id = self.party_id, bind_addr = %self.bind_addr))]
723    pub async fn start(&self) -> Result<()> {
724        if self.state() == ServerState::Shutdown {
725            return Err(Error::Configuration(
726                "cannot start a server after shutdown".to_owned(),
727            ));
728        }
729        if self.state() == ServerState::Running {
730            return Ok(());
731        }
732        let program = self.program.as_ref().ok_or_else(|| {
733            Error::Configuration("server start requires a compiled program".to_owned())
734        })?;
735        if (program.has_client_io() || self.expected_clients > 0)
736            && self.offchain_coordinator.is_none()
737        {
738            return Err(Error::Configuration(
739                "server start for ClientStore IO requires off-chain coordinator configuration"
740                    .to_owned(),
741            ));
742        }
743        let mpc_config = self
744            .mpc_config
745            .as_ref()
746            .ok_or_else(|| Error::Configuration("MPC configuration is required".to_owned()))?;
747        if self.party_id != 0 && self.bootstrap_addr.is_none() {
748            return Err(Error::Configuration(
749                "non-leader server start requires a bootstrap address".to_owned(),
750            ));
751        }
752
753        let runner_path = resolve_stoffel_run_binary(self.runner_path.as_deref())?;
754        eprintln!(
755            "[stoffel] using stoffel-run binary: {}",
756            runner_path.display()
757        );
758        let tempdir = tempfile::tempdir()?;
759        let program_path = tempdir.path().join("program.stflb");
760        program.save_bytecode(&program_path)?;
761
762        let args = self.runner_args(&program_path, mpc_config)?;
763        if self.advertise_addr.is_none() && bind_ip_is_unspecified(&self.bind_addr) {
764            eprintln!(
765                "[stoffel] warning: party {} binds {} without an advertise address; parties on other hosts cannot dial it",
766                self.party_id, self.bind_addr
767            );
768        }
769
770        let mut command = Command::new(runner_path);
771        command
772            .args(args)
773            .stdout(Stdio::null())
774            .stderr(Stdio::null());
775
776        let child = command.spawn()?;
777        *self.process.lock().map_err(|_| {
778            Error::Computation("server process state lock was poisoned".to_owned())
779        })? = Some(ServerProcess {
780            child,
781            _tempdir: tempdir,
782        });
783        self.set_state(ServerState::Running);
784        Ok(())
785    }
786
787    /// Build the `stoffel-run` argument list for this server.
788    fn runner_args(&self, program_path: &Path, mpc_config: &MpcConfig) -> Result<Vec<OsString>> {
789        let mut args: Vec<OsString> = vec![
790            program_path.into(),
791            (&self.entry).into(),
792            "--n-parties".into(),
793            mpc_config.parties.to_string().into(),
794            "--threshold".into(),
795            mpc_config.threshold.to_string().into(),
796            "--bind".into(),
797            (&self.bind_addr).into(),
798            "--mpc-backend".into(),
799            server_backend_name(self.backend).into(),
800        ];
801        if let Some(curve) = self.backend.curve() {
802            args.push("--mpc-curve".into());
803            args.push(curve.to_string().into());
804        }
805        if let Some(advertise) = self.advertise_addr {
806            args.push("--advertise".into());
807            args.push(advertise.to_string().into());
808        }
809        if let Some(config) = &self.offchain_coordinator {
810            config.validate(self.expected_clients)?;
811            args.push("--off-chain-coord".into());
812            args.push((&config.coordinator_address).into());
813            args.push("--rpc-bind".into());
814            args.push((&config.rpc_bind_address).into());
815            args.push("--key".into());
816            args.push((&config.key_path).into());
817            args.push("--cert".into());
818            args.push((&config.cert_path).into());
819            args.push("--timestamp".into());
820            args.push(config.timestamp.to_string().into());
821            if !config.expected_client_certs.is_empty() {
822                args.push("--expected-clients".into());
823                args.push(
824                    config
825                        .expected_client_certs
826                        .iter()
827                        .map(|path| path.display().to_string())
828                        .collect::<Vec<_>>()
829                        .join(",")
830                        .into(),
831                );
832            }
833        }
834        if let Some(bootstrap) = &self.bootstrap_addr {
835            args.push("--party-id".into());
836            args.push(self.party_id.to_string().into());
837            args.push("--bootstrap".into());
838            args.push(bootstrap.into());
839        } else {
840            if self.party_id != 0 {
841                return Err(Error::Configuration(
842                    "only party 0 can start as leader without bootstrap".to_owned(),
843                ));
844            }
845            args.push("--leader".into());
846        }
847        Ok(args)
848    }
849
850    #[tracing::instrument(skip_all, fields(party_id = self.party_id, bind_addr = %self.bind_addr))]
851    pub async fn run_forever(self) -> Result<()> {
852        self.start().await?;
853        let process = self
854            .process
855            .lock()
856            .map_err(|_| Error::Computation("server process state lock was poisoned".to_owned()))?
857            .take();
858        let status = if let Some(mut process) = process {
859            process.child.wait()?
860        } else {
861            std::future::pending::<std::process::ExitStatus>().await
862        };
863        self.set_state(ServerState::Shutdown);
864        if !status.success() {
865            return Err(Error::Computation(format!(
866                "stoffel-run for party {} exited unsuccessfully: {status}",
867                self.party_id
868            )));
869        }
870        Ok(())
871    }
872
873    #[tracing::instrument(skip_all, fields(party_id = self.party_id))]
874    pub async fn shutdown(self) -> Result<()> {
875        let process = self
876            .process
877            .lock()
878            .map_err(|_| Error::Computation("server process state lock was poisoned".to_owned()))?
879            .take();
880        if let Some(mut process) = process {
881            let _ = process.child.kill();
882            let _ = process.child.wait();
883        }
884        self.set_state(ServerState::Shutdown);
885        Ok(())
886    }
887
888    pub fn party_id(&self) -> PartyId {
889        self.party_id
890    }
891
892    pub fn state(&self) -> ServerState {
893        ServerState::from_u8(self.state.load(Ordering::Acquire))
894    }
895
896    pub fn verified_ordering(&self) -> Option<&VerifiedOrdering> {
897        self.verified_ordering.as_ref()
898    }
899
900    pub fn metrics(&self) -> &ServerMetrics {
901        &self.metrics
902    }
903
904    pub fn summary(&self) -> ServerSummary {
905        let (preprocessing_triples, preprocessing_random_shares) = self.preprocessing();
906        ServerSummary {
907            party_id: self.party_id,
908            bind_addr: self.bind_addr.clone(),
909            peer_count: self.peers.len(),
910            expected_clients: self.expected_clients,
911            preprocessing_triples,
912            preprocessing_random_shares,
913            consensus_timeout_ms: self
914                .consensus_timeout
915                .as_millis()
916                .try_into()
917                .unwrap_or(u64::MAX),
918            backend: self.backend,
919            mpc_parties: self.mpc_config.as_ref().map(|config| config.parties),
920            mpc_threshold: self.mpc_config.as_ref().map(|config| config.threshold),
921            mpc_instance_id: self.mpc_config.as_ref().map(|config| config.instance_id),
922            avss_engine_configured: self.avss_engine.is_some(),
923            avss_engine_live: self.avss_engine.as_ref().is_some_and(AvssEngine::is_live),
924            has_verified_ordering: self.verified_ordering.is_some(),
925            state: self.state(),
926            ready: self.ready(),
927            health: self.health(),
928        }
929    }
930
931    pub fn health(&self) -> HealthStatus {
932        match self.state() {
933            ServerState::Created => HealthStatus::Degraded {
934                reason: "server has been configured but not started by a live networking backend"
935                    .to_owned(),
936            },
937            ServerState::Running => HealthStatus::Healthy,
938            ServerState::Shutdown => HealthStatus::Unhealthy {
939                reason: "server has been shut down".to_owned(),
940            },
941        }
942    }
943
944    pub fn ready(&self) -> bool {
945        self.state() == ServerState::Running
946    }
947
948    #[tracing::instrument(skip_all, fields(party_id = self.party_id))]
949    pub async fn create_avss_engine(&self) -> Result<AvssEngine> {
950        match self.backend {
951            MpcBackend::Avss { curve } => Ok(self
952                .avss_engine
953                .clone()
954                .unwrap_or_else(|| AvssEngine::unavailable(curve))),
955            MpcBackend::HoneyBadger => Err(Error::Configuration(
956                "AVSS engine requires MpcBackend::Avss".to_owned(),
957            )),
958        }
959    }
960
961    pub fn bind_addr(&self) -> &str {
962        &self.bind_addr
963    }
964
965    pub fn peers(&self) -> &[(usize, String)] {
966        &self.peers
967    }
968
969    pub fn program(&self) -> Option<&Program> {
970        self.program.as_ref()
971    }
972
973    pub fn preprocessing(&self) -> (usize, usize) {
974        (self.triples, self.random_shares)
975    }
976
977    pub fn expected_clients(&self) -> usize {
978        self.expected_clients
979    }
980
981    pub fn consensus_timeout(&self) -> Duration {
982        self.consensus_timeout
983    }
984
985    pub fn backend(&self) -> MpcBackend {
986        self.backend
987    }
988
989    pub fn mpc_config(&self) -> Option<&MpcConfig> {
990        self.mpc_config.as_ref()
991    }
992
993    pub fn avss_engine(&self) -> Option<&AvssEngine> {
994        self.avss_engine.as_ref()
995    }
996
997    pub fn runner_path(&self) -> Option<&Path> {
998        self.runner_path.as_deref()
999    }
1000
1001    pub fn bootstrap_addr(&self) -> Option<&str> {
1002        self.bootstrap_addr.as_deref()
1003    }
1004
1005    /// The address forwarded to `stoffel-run` as `--advertise`, if any.
1006    pub fn advertise_addr(&self) -> Option<SocketAddr> {
1007        self.advertise_addr
1008    }
1009
1010    pub fn offchain_coordinator(&self) -> Option<&OffChainServerConfig> {
1011        self.offchain_coordinator.as_ref()
1012    }
1013
1014    pub fn entry(&self) -> &str {
1015        &self.entry
1016    }
1017
1018    fn set_state(&self, state: ServerState) {
1019        self.state.store(state.as_u8(), Ordering::Release);
1020    }
1021}
1022
1023fn server_backend_name(backend: MpcBackend) -> &'static str {
1024    match backend {
1025        MpcBackend::HoneyBadger => "honeybadger",
1026        MpcBackend::Avss { .. } => "avss",
1027    }
1028}
1029
1030fn bind_ip_is_unspecified(bind_addr: &str) -> bool {
1031    bind_addr
1032        .parse::<SocketAddr>()
1033        .is_ok_and(|addr| addr.ip().is_unspecified())
1034}
1035
1036fn resolve_stoffel_run_binary(explicit_path: Option<&Path>) -> Result<PathBuf> {
1037    if let Some(path) = explicit_path {
1038        return path.exists().then(|| path.to_path_buf()).ok_or_else(|| {
1039            Error::Unsupported(format!(
1040                "server start requires an existing stoffel-run binary; configured path does not exist: {}",
1041                path.display()
1042            ))
1043        });
1044    }
1045    if let Some(path) = std::env::var_os("STOFFEL_RUN_BIN").map(PathBuf::from) {
1046        return path.exists().then_some(path.clone()).ok_or_else(|| {
1047            Error::Unsupported(format!(
1048                "server start requires an existing stoffel-run binary; STOFFEL_RUN_BIN points to a missing path: {}",
1049                path.display()
1050            ))
1051        });
1052    }
1053    let candidate = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1054        .parent()
1055        .and_then(Path::parent)
1056        .map(|root| root.join("target").join("debug").join("stoffel-run"));
1057    candidate
1058        .filter(|path| path.exists())
1059        .ok_or_else(|| {
1060            Error::Unsupported(
1061                "server start requires a built stoffel-run binary; set STOFFEL_RUN_BIN, call runner_path, or build `cargo build -p stoffel-vm-runner --bin stoffel-run`"
1062                    .to_owned(),
1063            )
1064        })
1065}
1066
1067fn validate_existing_file(label: &str, path: &Path) -> Result<()> {
1068    if path.as_os_str().is_empty() {
1069        return Err(Error::Configuration(format!(
1070            "{label} path must not be empty"
1071        )));
1072    }
1073    if !path.is_file() {
1074        return Err(Error::Configuration(format!(
1075            "{label} path does not exist or is not a file: {}",
1076            path.display()
1077        )));
1078    }
1079    Ok(())
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084    use super::*;
1085
1086    #[tokio::test]
1087    async fn configured_avss_engine_is_returned_by_server_api() -> Result<()> {
1088        let engine = AvssEngine::unavailable(Curve::Bls12_381);
1089        let server = StoffelServer::builder(0)
1090            .bind("127.0.0.1:1")
1091            .avss(Curve::Bls12_381)
1092            .with_avss_engine(engine)
1093            .build()?;
1094
1095        assert!(server.avss_engine().is_some());
1096        assert!(server.summary().avss_engine_configured);
1097        let returned = server.create_avss_engine().await?;
1098        assert_eq!(returned.curve(), Curve::Bls12_381);
1099        assert!(!returned.is_live());
1100        Ok(())
1101    }
1102
1103    #[test]
1104    fn configured_avss_engine_requires_matching_avss_backend() {
1105        let err = StoffelServer::builder(0)
1106            .bind("127.0.0.1:1")
1107            .honeybadger()
1108            .with_avss_engine(AvssEngine::unavailable(Curve::Bls12_381))
1109            .build()
1110            .unwrap_err();
1111        assert!(matches!(err, Error::Configuration(_)));
1112
1113        let err = StoffelServer::builder(0)
1114            .bind("127.0.0.1:1")
1115            .avss(Curve::Bn254)
1116            .with_avss_engine(AvssEngine::unavailable(Curve::Bls12_381))
1117            .build()
1118            .unwrap_err();
1119        assert!(matches!(err, Error::Configuration(_)));
1120    }
1121
1122    fn runner_args_for(server: &StoffelServer) -> Result<Vec<OsString>> {
1123        let mpc_config = server
1124            .mpc_config()
1125            .expect("test server has an MPC configuration");
1126        server.runner_args(Path::new("program.stflb"), mpc_config)
1127    }
1128
1129    fn five_party_builder(party_id: PartyId) -> ServerBuilder {
1130        let mpc_config = MpcConfig::default();
1131        StoffelServer::builder(party_id)
1132            .bind("127.0.0.1:19200")
1133            .mpc_config(&mpc_config)
1134            .peers(
1135                (0..5)
1136                    .filter(|peer| *peer != party_id)
1137                    .map(|peer| (peer, format!("127.0.0.1:{}", 19300 + peer))),
1138            )
1139    }
1140
1141    fn advertise_values(args: &[OsString]) -> Vec<&OsString> {
1142        args.iter()
1143            .zip(args.iter().skip(1))
1144            .filter(|(flag, _)| *flag == "--advertise")
1145            .map(|(_, value)| value)
1146            .collect()
1147    }
1148
1149    #[test]
1150    fn advertise_defaults_to_unset_and_is_not_forwarded() -> Result<()> {
1151        let builder = five_party_builder(0);
1152        assert_eq!(builder.configured_advertise(), None);
1153
1154        let server = builder.build()?;
1155        assert_eq!(server.advertise_addr(), None);
1156        let args = runner_args_for(&server)?;
1157        assert!(!args.iter().any(|arg| arg == "--advertise"));
1158        assert_eq!(
1159            args.last().map(OsString::as_os_str),
1160            Some("--leader".as_ref())
1161        );
1162        Ok(())
1163    }
1164
1165    #[test]
1166    fn advertise_round_trips_and_is_forwarded_for_leader_and_follower() -> Result<()> {
1167        let expected: SocketAddr = "10.0.0.5:20200".parse().expect("numeric socket address");
1168
1169        let builder = five_party_builder(0).advertise("10.0.0.5:20200");
1170        assert_eq!(builder.configured_advertise(), Some(expected));
1171        let leader = builder.build()?;
1172        assert_eq!(leader.advertise_addr(), Some(expected));
1173        let args = runner_args_for(&leader)?;
1174        assert_eq!(advertise_values(&args), ["10.0.0.5:20200"]);
1175        assert!(args.iter().any(|arg| arg == "--leader"));
1176
1177        let follower = five_party_builder(2)
1178            .bootstrap("10.0.0.4:19200")
1179            .advertise(expected)
1180            .build()?;
1181        let args = runner_args_for(&follower)?;
1182        assert_eq!(advertise_values(&args), ["10.0.0.5:20200"]);
1183        assert!(args.iter().any(|arg| arg == "--bootstrap"));
1184        assert!(!args.iter().any(|arg| arg == "--leader"));
1185        Ok(())
1186    }
1187
1188    #[test]
1189    fn advertise_accepts_loopback_for_single_host_networks() -> Result<()> {
1190        let server = five_party_builder(0).advertise("127.0.0.1:20200").build()?;
1191        assert_eq!(
1192            server.advertise_addr(),
1193            Some("127.0.0.1:20200".parse().expect("numeric socket address"))
1194        );
1195        Ok(())
1196    }
1197
1198    #[test]
1199    fn advertise_rejects_undialable_addresses() {
1200        for address in ["0.0.0.0:20200", "[::]:20200", "10.0.0.5:0"] {
1201            let err = five_party_builder(0)
1202                .advertise(address)
1203                .build()
1204                .unwrap_err();
1205            assert!(
1206                matches!(&err, Error::Configuration(message) if message.contains("advertise")),
1207                "{address}: {err}"
1208            );
1209        }
1210
1211        let err = five_party_builder(0)
1212            .advertise("not a socket address")
1213            .build()
1214            .unwrap_err();
1215        assert!(
1216            matches!(&err, Error::Configuration(message) if message.contains("invalid server advertise address")),
1217            "{err}"
1218        );
1219    }
1220
1221    #[test]
1222    fn non_leader_without_bootstrap_has_no_runner_args() -> Result<()> {
1223        let server = five_party_builder(1).build()?;
1224        let err = runner_args_for(&server).unwrap_err();
1225        assert!(matches!(err, Error::Configuration(_)));
1226        Ok(())
1227    }
1228
1229    #[test]
1230    fn unspecified_bind_detection_only_matches_wildcard_addresses() {
1231        assert!(bind_ip_is_unspecified("0.0.0.0:19200"));
1232        assert!(bind_ip_is_unspecified("[::]:19200"));
1233        assert!(!bind_ip_is_unspecified("127.0.0.1:19200"));
1234        assert!(!bind_ip_is_unspecified("10.0.0.5:19200"));
1235    }
1236}