breez_sdk_spark/sdk_context.rs
1use std::sync::Arc;
2
3use breez_sdk_common::breez_server::{BreezServer, PRODUCTION_BREEZSERVER_URL};
4use platform_utils::{HttpClient, create_http_client};
5
6use spark_wallet::{BalancedConnectionManager, ConnectionManager, DefaultConnectionManager};
7
8use crate::{
9 Network, SdkError, default_user_agent, jwt_header_provider::BreezJwtHeaderProvider,
10 persist::backend::StorageBackend,
11};
12
13/// Process-shared resources that can back many `BreezSdk` instances.
14///
15/// Construct one with [`new_shared_sdk_context`] and pass the same `Arc` to every
16/// [`SdkBuilder`](crate::SdkBuilder) whose SDKs should share those resources
17/// (a single HTTP client across SSP / chain / LNURL / JWT / etc., a gRPC
18/// channel pool to the Spark operators, the Breez backend gRPC client, …).
19/// Useful for multi-tenant servers that load many wallets in one process.
20///
21/// To share a database connection pool across SDKs, pass a
22/// [`StorageBackend`](crate::StorageBackend) as
23/// [`SdkContextConfig::storage`]: every SDK built from the context reuses it.
24///
25/// The struct is intentionally opaque — all fields are crate-private. There
26/// is no way to inject pre-built sub-components: the factory builds them
27/// from settings so callers don't need to know about session stores or
28/// connection-manager wiring.
29#[cfg_attr(feature = "uniffi", derive(uniffi::Object))]
30pub struct SdkContext {
31 /// Single shared HTTP client used for every reqwest-based call out of the
32 /// SDK: SSP GraphQL, chain service, LNURL, JWT fetch, etc.
33 pub(crate) http_client: Arc<dyn HttpClient>,
34 /// Single shared gRPC client to the Breez backend (fiat, `MoonPay`, payment
35 /// notifier, signer, support, swapper).
36 pub(crate) breez_server: Arc<BreezServer>,
37 /// Shared Breez partner JWT header provider. Only set when
38 /// `network == Mainnet && api_key.is_some()` at context construction.
39 /// All SDKs sharing the context reuse one in-memory JWT and one
40 /// background refresh task.
41 pub(crate) jwt_header_provider: Option<Arc<BreezJwtHeaderProvider>>,
42 /// The network the context was built for. Kept so `SdkBuilder::build()`
43 /// can cross-check against `Config.network` and refuse a mismatch.
44 pub(crate) network: Network,
45 /// The api key the context was built with. Kept so `SdkBuilder::build()`
46 /// can cross-check against `Config.api_key` and refuse a mismatch.
47 pub(crate) api_key: Option<String>,
48 pub(crate) connection_manager: Arc<dyn ConnectionManager>,
49 /// The storage backend SDKs built from this context share. `None` when the
50 /// context carries no storage; each `SdkBuilder` then supplies its own.
51 pub(crate) storage_backend: Option<Arc<dyn StorageBackend>>,
52}
53
54/// Settings for [`new_shared_sdk_context`]. All fields are optional; the defaults
55/// match the single-SDK happy path.
56#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
57pub struct SdkContextConfig {
58 /// Network the shared resources target. Defaults to [`Network::Mainnet`].
59 /// Used to gate the partner JWT header provider — only constructed on
60 /// Mainnet, since Regtest has no JWT-issuing Breez endpoint.
61 pub network: Network,
62
63 /// Breez API key. When set together with `network == Mainnet`, the
64 /// context constructs a shared partner JWT header provider that all
65 /// SDKs built from this context will attach to their SO requests.
66 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
67 pub api_key: Option<String>,
68
69 /// Number of gRPC connections per Spark operator. `None` (or `Some(1)`)
70 /// keeps a single connection per operator (the right choice for most
71 /// deployments); `Some(n)` opens `n` channels per operator and balances
72 /// requests across them.
73 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
74 pub connections_per_operator: Option<u32>,
75
76 /// Shared storage backend for SDKs built from this context. When set,
77 /// every SDK built from the context reuses it (and its database
78 /// connection pool). Construct via
79 /// [`default_storage`](crate::default_storage),
80 /// [`postgres_storage`](crate::postgres_storage),
81 /// [`mysql_storage`](crate::mysql_storage) or
82 /// [`custom_storage`](crate::custom_storage).
83 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
84 pub storage: Option<Arc<dyn StorageBackend>>,
85}
86
87impl SdkContextConfig {
88 /// Config with the given network and every other field defaulted. Use
89 /// directly for the bare case, or with struct update syntax to override
90 /// specific fields: `SdkContextConfig { storage: Some(storage),
91 /// ..SdkContextConfig::new(network) }`.
92 #[must_use]
93 pub fn new(network: Network) -> Self {
94 Self {
95 network,
96 api_key: None,
97 connections_per_operator: None,
98 storage: None,
99 }
100 }
101}
102
103/// Constructs an [`SdkContext`] from a `SdkContextConfig`.
104///
105/// The returned `Arc` is cheap to clone and can back many SDK instances,
106/// sharing their HTTP client and operator gRPC channels.
107// Async-on-tokio so UniFFI runs it on the managed runtime: building the
108// shared resources `tokio::spawn`s internally (gRPC channel; mainnet JWT
109// task) and aborts off-runtime, despite no `.await` here.
110#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
111pub async fn new_shared_sdk_context(config: SdkContextConfig) -> Result<Arc<SdkContext>, SdkError> {
112 let user_agent = default_user_agent();
113 let http_client = create_http_client(Some(&user_agent));
114 let breez_server = Arc::new(
115 BreezServer::new(PRODUCTION_BREEZSERVER_URL, None, &user_agent)
116 .map_err(|e| SdkError::Generic(e.to_string()))?,
117 );
118 // The Breez partner JWT is only issued by the mainnet Breez endpoint, and
119 // only when an API key is configured. Skip the provider entirely otherwise
120 // — there is no token to fetch. SDKs sharing this context will share the
121 // one in-memory JWT and one background refresh task.
122 let api_key = config.api_key;
123 let jwt_header_provider = if matches!(config.network, Network::Mainnet)
124 && let Some(ref key) = api_key
125 {
126 Some(BreezJwtHeaderProvider::new(
127 key.clone(),
128 None,
129 http_client.clone(),
130 ))
131 } else {
132 None
133 };
134 // SDKs that share the same context share the same gRPC channels to the
135 // Spark operators. `connections_per_operator` lets the rare deployment
136 // open multiple connections per operator and balance requests across
137 // them; `None` (or `Some(1)`) keeps a single multiplexed connection.
138 let connection_manager: Arc<dyn ConnectionManager> = match config.connections_per_operator {
139 Some(n) if n > 1 => Arc::new(BalancedConnectionManager::new(n)),
140 _ => Arc::new(DefaultConnectionManager::new()),
141 };
142
143 // Every SDK built from this context shares the one storage backend (and
144 // its database connection pool).
145 let storage_backend = config.storage;
146
147 Ok(Arc::new(SdkContext {
148 http_client,
149 breez_server,
150 jwt_header_provider,
151 network: config.network,
152 api_key,
153 connection_manager,
154 storage_backend,
155 }))
156}
157
158#[cfg(all(test, not(target_family = "wasm")))]
159mod tests {
160 use super::*;
161
162 #[tokio::test]
163 async fn default_config_yields_context_with_shared_clients_and_no_db() {
164 let ctx = new_shared_sdk_context(SdkContextConfig::new(Network::Regtest))
165 .await
166 .expect("default context");
167 // Just confirming the Arcs are non-null.
168 let _http = Arc::clone(&ctx.http_client);
169 let _breez = Arc::clone(&ctx.breez_server);
170 let _so = Arc::clone(&ctx.connection_manager);
171 // Default config has no api_key, so no JWT provider is constructed.
172 assert!(ctx.jwt_header_provider.is_none());
173 // Network and api_key are stored verbatim for the builder cross-check.
174 assert_eq!(ctx.network, Network::Regtest);
175 assert!(ctx.api_key.is_none());
176 assert!(ctx.storage_backend.is_none());
177 }
178
179 #[tokio::test]
180 async fn mainnet_with_api_key_constructs_jwt_provider_and_stores_inputs() {
181 let ctx = new_shared_sdk_context(SdkContextConfig {
182 api_key: Some("test-key".to_string()),
183 ..SdkContextConfig::new(Network::Mainnet)
184 })
185 .await
186 .expect("mainnet context");
187 assert!(ctx.jwt_header_provider.is_some());
188 assert_eq!(ctx.network, Network::Mainnet);
189 assert_eq!(ctx.api_key.as_deref(), Some("test-key"));
190 }
191
192 #[tokio::test]
193 async fn regtest_with_api_key_skips_jwt_but_still_stores_inputs() {
194 let ctx = new_shared_sdk_context(SdkContextConfig {
195 api_key: Some("test-key".to_string()),
196 ..SdkContextConfig::new(Network::Regtest)
197 })
198 .await
199 .expect("regtest context");
200 // Regtest never gets a JWT provider — there's no Breez endpoint to
201 // mint a token. But the inputs are still stored so the builder
202 // cross-check can detect a network mismatch.
203 assert!(ctx.jwt_header_provider.is_none());
204 assert_eq!(ctx.network, Network::Regtest);
205 assert_eq!(ctx.api_key.as_deref(), Some("test-key"));
206 }
207}