Skip to main content

taceo_nodes_common/
lib.rs

1#![deny(missing_docs)]
2#![deny(clippy::all, clippy::pedantic)]
3#![deny(
4    clippy::allow_attributes_without_reason,
5    clippy::assertions_on_result_states,
6    clippy::dbg_macro,
7    clippy::decimal_literal_representation,
8    clippy::exhaustive_enums,
9    clippy::exhaustive_structs,
10    clippy::iter_over_hash_type,
11    clippy::let_underscore_must_use,
12    clippy::missing_assert_message,
13    clippy::print_stderr,
14    clippy::print_stdout,
15    clippy::undocumented_unsafe_blocks,
16    clippy::unnecessary_safety_comment,
17    clippy::unwrap_used
18)]
19#![allow(clippy::needless_pass_by_value, reason = "Needed for axum")]
20//! Common utilities for MPC-node services.
21//!
22//! This crate provides building blocks shared across nodes in the MPC network.
23//!
24//! * [`Environment`] – represents the deployment environment (prod / staging / test).
25//! * [`StartedServices`] – tracks whether all async background services have started,
26//!   used to drive the `/health` endpoint.
27//! * [`spawn_shutdown_task`] / [`default_shutdown_signal`] – wiring for graceful shutdown
28//!   via `CTRL+C` or `SIGTERM`.
29//! * [`version_info!`] – macro that returns a version string containing the crate name,
30//!   semver version, and git hash.
31//!
32//! # Optional Features
33//!
34//! * `api` (enabled by default) – exposes `/health` and `/version` Axum endpoints.
35//! * `serde` (enabled by default) – ser/de implementation for [`Environment`].
36//! * `aws` (enabled by default) – adds a method to create a localstack configuration used for testing.
37//! * `postgres` (enabled by default) – [`postgres::PostgresConfig`] and [`postgres::pg_pool_with_schema`] for creating a `sqlx` connection pool pinned to a schema, with configurable retry behaviour."
38//! * `alloy` (enabled by default) – [`web3::RpcProvider`], [`web3::RpcProviderBuilder`], and [`web3::RpcProviderConfig`] for building HTTP + WebSocket Ethereum RPC providers with automatic retry and failover, plus ERC-165 interface detection utilities.
39
40use core::fmt;
41use std::sync::{
42    Arc, Mutex,
43    atomic::{AtomicBool, Ordering},
44};
45
46#[cfg(feature = "serde")]
47use serde::{Deserialize, Serialize};
48use tokio::signal;
49use tokio_util::sync::CancellationToken;
50
51pub use git_version;
52
53#[cfg(feature = "api")]
54/// See [`api::routes`] and [`api::routes_with_services`].
55pub mod api;
56#[cfg(feature = "postgres")]
57pub mod postgres;
58#[cfg(feature = "alloy")]
59pub mod web3;
60
61/// The environment the service is running in.
62///
63/// Main usage for the `Environment` is to call
64/// [`Environment::assert_is_dev`]. Services that are intended
65/// for `dev` only (like local secret-manager,...)
66/// shall assert that they are called from the `dev` environment.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
68#[allow(
69    clippy::exhaustive_enums,
70    reason = "We only expect those four environments at the moment. Changing that is a breaking change."
71)]
72#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
73#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
74pub enum Environment {
75    /// Production environment.
76    Prod,
77    /// Staging environment.
78    Stage,
79    /// Test environment. Used for deployed test nets not for local testing. Use `Dev` instead for local testing.
80    Test,
81    /// Local dev environment.
82    Dev,
83}
84
85impl fmt::Display for Environment {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        let str = match self {
88            Environment::Prod => "prod",
89            Environment::Stage => "stage",
90            Environment::Test => "test",
91            Environment::Dev => "dev",
92        };
93        f.write_str(str)
94    }
95}
96
97impl Environment {
98    /// Asserts that the environment is the dev environment.
99    ///
100    /// # Panics
101    ///
102    /// Panics with `"Is not dev environment"` if `self` is not `Environment::Dev`.
103    pub fn assert_is_dev(&self) {
104        assert!(self.is_dev(), "Is not dev environment");
105    }
106
107    /// Returns `true` if the environment is the test environment.
108    #[must_use]
109    pub fn is_dev(&self) -> bool {
110        matches!(self, Environment::Dev)
111    }
112
113    /// Returns `true` if the environment is not the test environment.
114    #[must_use]
115    pub fn is_not_dev(&self) -> bool {
116        !self.is_dev()
117    }
118}
119
120/// Macro to generate version information including the crate name, version, and git hash.
121#[macro_export]
122macro_rules! version_info {
123    () => {
124        format!(
125            "{} {} ({})",
126            env!("CARGO_PKG_NAME"),
127            env!("CARGO_PKG_VERSION"),
128            option_env!("GIT_HASH")
129                .unwrap_or($crate::git_version::git_version!(fallback = "UNKNOWN"))
130        )
131    };
132}
133
134/// A struct that keeps track of the health of all async services started by the service.
135///
136/// Relevant for the `/health` route. Implementations should call [`StartedServices::new_service`] for their services and set the bool to `true` if the service started successfully.
137#[derive(Debug, Clone, Default)]
138pub struct StartedServices {
139    external_service: Arc<Mutex<Vec<Arc<AtomicBool>>>>,
140}
141
142impl StartedServices {
143    /// Initializes all services as not started.
144    #[must_use]
145    pub fn new() -> Self {
146        Self::default()
147    }
148
149    /// Adds a new external service to the bookkeeping struct.
150    ///
151    /// Implementations should call this method for every async task that they start. The returned `AtomicBool` should then be set to `true` if the service is ready.
152    #[must_use]
153    #[allow(clippy::missing_panics_doc, reason = "Ok to panic for lock poisoning")]
154    pub fn new_service(&self) -> Arc<AtomicBool> {
155        let service = Arc::new(AtomicBool::default());
156        self.external_service
157            .lock()
158            .expect("Not poisoned")
159            .push(Arc::clone(&service));
160        service
161    }
162
163    /// Returns `true` if all services did start. If there are no services started, this will also return `true`.
164    #[must_use]
165    #[allow(clippy::missing_panics_doc, reason = "Ok to panic for lock poisoning")]
166    pub fn all_started(&self) -> bool {
167        self.external_service
168            .lock()
169            .expect("Not poisoned")
170            .iter()
171            .all(|service| service.load(Ordering::Relaxed))
172    }
173}
174
175/// Spawns a shutdown task and creates an associated [`CancellationToken`](https://docs.rs/tokio-util/latest/tokio_util/sync/struct.CancellationToken.html). This task will complete when either the provided `shutdown_signal` futures completes or if some other tasks cancels the shutdown token. The associated shutdown token will be cancelled either way.
176///
177/// Waiting for the shutdown token is the preferred way to wait for termination.
178pub fn spawn_shutdown_task(
179    shutdown_signal: impl Future<Output = ()> + Send + 'static,
180) -> (CancellationToken, Arc<AtomicBool>) {
181    let cancellation_token = CancellationToken::new();
182    let is_graceful = Arc::new(AtomicBool::new(false));
183    let task_token = cancellation_token.clone();
184    tokio::task::spawn({
185        let is_graceful = Arc::clone(&is_graceful);
186        async move {
187            let _drop_guard = task_token.drop_guard_ref();
188            tokio::select! {
189                () = shutdown_signal => {
190                    tracing::info!("received graceful shutdown");
191                    is_graceful.store(true, Ordering::Relaxed);
192                    task_token.cancel();
193                }
194                () = task_token.cancelled() => {}
195            }
196        }
197    });
198    (cancellation_token, is_graceful)
199}
200
201/// Returns a future that completes when the application should shut down.
202///
203/// On most systems, it completes when the user presses `CTRL+C`.
204/// On Unix platforms, it also responds to the `SIGTERM` signal.
205///
206/// # Panics
207///
208/// Panics if the `CTRL+C` or `SIGTERM` signal handlers cannot be installed.
209pub async fn default_shutdown_signal() {
210    let ctrl_c = async {
211        signal::ctrl_c()
212            .await
213            .expect("failed to install Ctrl+C handler");
214    };
215
216    #[cfg(unix)]
217    let terminate = async {
218        signal::unix::signal(signal::unix::SignalKind::terminate())
219            .expect("failed to install signal handler")
220            .recv()
221            .await;
222    };
223
224    #[cfg(not(unix))]
225    let terminate = std::future::pending::<()>();
226
227    tokio::select! {
228        () = ctrl_c => {},
229        () = terminate => {},
230    }
231}
232
233#[cfg(feature = "aws")]
234/// Creates an AWS SDK configuration for connecting to a `LocalStack` instance.
235///
236/// This function is designed to facilitate testing and development by configuring
237/// an AWS SDK client to connect to a `LocalStack` instance. It sets the region to
238/// `us-east-1` and uses static test credentials. The endpoint URL can be customized
239/// via the `TEST_AWS_ENDPOINT_URL` environment variable; if not set, it defaults
240/// to `http://localhost:4566`.
241pub async fn localstack_aws_config() -> aws_config::SdkConfig {
242    use aws_config::Region;
243    use aws_sdk_secretsmanager::config::Credentials;
244    let region_provider = Region::new("us-east-1");
245    let credentials = Credentials::new("test", "test", None, None, "Static");
246    // in case we don't want the standard url, we can configure it via the environment
247    aws_config::from_env()
248        .region(region_provider)
249        .endpoint_url(
250            std::env::var("TEST_AWS_ENDPOINT_URL").unwrap_or("http://localhost:4566".to_string()),
251        )
252        .credentials_provider(credentials)
253        .load()
254        .await
255}