feat: wire chain clients into AppState (feature-gated)

Add ChainClients (infra/chains.rs): per-chain data-source handles built once at
startup from config and shared via AppState. Each client is present only when
its chain feature flag is on, and a malformed chain endpoint now fails startup
(fail-closed) rather than the first request. Cloneable (grin's session state is
shared via Arc) so it lives in the cloneable AppState.

Foundation for the wallet-handlers phase — handlers will read these clients
from State. No endpoints yet (openapi unchanged).
This commit is contained in:
jwinterm
2026-06-23 10:39:25 -04:00
parent e2beb17dda
commit b7e3199200
5 changed files with 65 additions and 2 deletions
+53
View File
@@ -0,0 +1,53 @@
//! Per-chain client handles, constructed once at startup and shared via
//! [`crate::AppState`]. Each chain's client is config-pointed at an external node
//! service (Fulcrum/Electrum, monero/wownero-lws, grin-wallet + grin node) and is
//! present only when that chain's feature flag is enabled. A malformed endpoint
//! fails startup (fail-closed), not the first request.
use std::sync::Arc;
use crate::config::Config;
use crate::error::AppError;
use crate::infra::electrum::ElectrumClient;
use crate::infra::grin::GrinClient;
use crate::infra::lws::LwsClient;
/// The enabled chains' data-source clients. Cloneable (the heavyweight grin
/// session state is shared via `Arc`) so it can live in the cloneable `AppState`.
#[derive(Clone)]
pub struct ChainClients {
pub btc: Option<ElectrumClient>,
pub ltc: Option<ElectrumClient>,
pub xmr: Option<LwsClient>,
pub wow: Option<LwsClient>,
pub grin: Option<Arc<GrinClient>>,
}
impl ChainClients {
/// Build clients for the enabled chains from config.
pub fn from_config(cfg: &Config) -> Result<Self, AppError> {
let flags = &cfg.features.chains;
Ok(Self {
btc: flags
.btc
.then(|| ElectrumClient::bitcoin(&cfg.chains.btc))
.transpose()?,
ltc: flags
.ltc
.then(|| ElectrumClient::litecoin(&cfg.chains.ltc))
.transpose()?,
xmr: flags
.xmr
.then(|| LwsClient::monero(&cfg.chains.xmr))
.transpose()?,
wow: flags
.wow
.then(|| LwsClient::wownero(&cfg.chains.wow))
.transpose()?,
grin: flags
.grin
.then(|| GrinClient::new(&cfg.chains.grin).map(Arc::new))
.transpose()?,
})
}
}
+1
View File
@@ -1,6 +1,7 @@
//! Infrastructure layer: adapters for external services (database, chain nodes, //! Infrastructure layer: adapters for external services (database, chain nodes,
//! light-wallet servers). Chain clients are wired in as they land. //! light-wallet servers). Chain clients are wired in as they land.
pub mod chains;
pub mod db; pub mod db;
pub mod electrum; pub mod electrum;
pub mod grin; pub mod grin;
+3
View File
@@ -23,6 +23,7 @@ use tokio::sync::RwLock;
use crate::config::Config; use crate::config::Config;
use crate::core::session::{SessionManager, WebChallenge}; use crate::core::session::{SessionManager, WebChallenge};
use crate::infra::chains::ChainClients;
use crate::infra::db::Database; use crate::infra::db::Database;
/// Shared application state injected into handlers via `State<Arc<AppState>>`. /// Shared application state injected into handlers via `State<Arc<AppState>>`.
@@ -31,6 +32,8 @@ pub struct AppState {
pub config: Config, pub config: Config,
pub db: Database, pub db: Database,
pub sessions: SessionManager, pub sessions: SessionManager,
/// Per-chain data-source clients (present only for enabled chains).
pub chains: ChainClients,
/// In-memory website-auth challenges, keyed by nonce. Single-node store; /// In-memory website-auth challenges, keyed by nonce. Single-node store;
/// a shared/stateless variant is the load-balanced-fleet path. /// a shared/stateless variant is the load-balanced-fleet path.
pub web_challenges: Arc<RwLock<HashMap<String, WebChallenge>>>, pub web_challenges: Arc<RwLock<HashMap<String, WebChallenge>>>,
+4 -1
View File
@@ -9,7 +9,8 @@ use std::sync::Arc;
use sqlx::postgres::PgPoolOptions; use sqlx::postgres::PgPoolOptions;
use smirk_backend_core::{ use smirk_backend_core::{
build_router, config::Config, core::session::SessionManager, infra::db::Database, AppState, build_router, config::Config, core::session::SessionManager, infra::chains::ChainClients,
infra::db::Database, AppState,
}; };
#[tokio::main] #[tokio::main]
@@ -33,12 +34,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
config.secrets.ip_salt.clone(), config.secrets.ip_salt.clone(),
); );
let sessions = SessionManager::new(&config.auth.jwt_secret, config.auth.jwt_expiry_hours); let sessions = SessionManager::new(&config.auth.jwt_secret, config.auth.jwt_expiry_hours);
let chains = ChainClients::from_config(&config)?;
let addr = format!("{}:{}", config.server_host, config.server_port); let addr = format!("{}:{}", config.server_host, config.server_port);
let state = Arc::new(AppState { let state = Arc::new(AppState {
config, config,
db, db,
sessions, sessions,
chains,
web_challenges: Arc::default(), web_challenges: Arc::default(),
}); });
+4 -1
View File
@@ -21,7 +21,8 @@ use tower::ServiceExt;
use uuid::Uuid; use uuid::Uuid;
use smirk_backend_core::{ use smirk_backend_core::{
build_router, config::Config, core::session::SessionManager, infra::db::Database, AppState, build_router, config::Config, core::session::SessionManager, infra::chains::ChainClients,
infra::db::Database, AppState,
}; };
static INIT_ENV: Once = Once::new(); static INIT_ENV: Once = Once::new();
@@ -68,10 +69,12 @@ pub async fn try_app() -> Option<TestApp> {
config.secrets.ip_salt.clone(), config.secrets.ip_salt.clone(),
); );
let sessions = SessionManager::new(&config.auth.jwt_secret, config.auth.jwt_expiry_hours); let sessions = SessionManager::new(&config.auth.jwt_secret, config.auth.jwt_expiry_hours);
let chains = ChainClients::from_config(&config).expect("build chain clients");
let state = Arc::new(AppState { let state = Arc::new(AppState {
config, config,
db, db,
sessions, sessions,
chains,
web_challenges: Arc::default(), web_challenges: Arc::default(),
}); });