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_with_proxy};
5
6use spark_wallet::{BalancedConnectionManager, ConnectionManager, DefaultConnectionManager};
7
8use crate::{
9 Network, ProxyConfig, SdkError, default_user_agent,
10 jwt_header_provider::BreezJwtHeaderProvider, 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 proxy every client in this context was built with. Kept so
50 /// `SdkBuilder::build()` can cross-check against `Config.proxy` and refuse
51 /// a mismatch, and so per-SDK components (DNS, relays) match the shared
52 /// clients.
53 pub(crate) proxy: Option<ProxyConfig>,
54 /// The storage backend SDKs built from this context share. `None` when the
55 /// context carries no storage; each `SdkBuilder` then supplies its own.
56 pub(crate) storage_backend: Option<Arc<dyn StorageBackend>>,
57}
58
59/// Settings for [`new_shared_sdk_context`]. All fields are optional; the defaults
60/// match the single-SDK happy path.
61#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
62pub struct SdkContextConfig {
63 /// Network the shared resources target. Defaults to [`Network::Mainnet`].
64 /// Used to gate the partner JWT header provider — only constructed on
65 /// Mainnet, since Regtest has no JWT-issuing Breez endpoint.
66 pub network: Network,
67
68 /// Breez API key. When set together with `network == Mainnet`, the
69 /// context constructs a shared partner JWT header provider that all
70 /// SDKs built from this context will attach to their SO requests.
71 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
72 pub api_key: Option<String>,
73
74 /// Number of gRPC connections per Spark operator. `None` (or `Some(1)`)
75 /// keeps a single connection per operator (the right choice for most
76 /// deployments); `Some(n)` opens `n` channels per operator and balances
77 /// requests across them.
78 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
79 pub connections_per_operator: Option<u32>,
80
81 /// Routes the connections opened by this context's shared clients through
82 /// a SOCKS5 proxy. Must match the `proxy` on the `Config` of every SDK
83 /// built from this context.
84 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
85 pub proxy: Option<ProxyConfig>,
86
87 /// Shared storage backend for SDKs built from this context. When set,
88 /// every SDK built from the context reuses it (and its database
89 /// connection pool). Construct via
90 /// [`default_storage`](crate::default_storage),
91 /// [`postgres_storage`](crate::postgres_storage),
92 /// [`mysql_storage`](crate::mysql_storage) or
93 /// [`custom_storage`](crate::custom_storage).
94 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
95 pub storage: Option<Arc<dyn StorageBackend>>,
96}
97
98impl SdkContextConfig {
99 /// Config with the given network and every other field defaulted. Use
100 /// directly for the bare case, or with struct update syntax to override
101 /// specific fields: `SdkContextConfig { storage: Some(storage),
102 /// ..SdkContextConfig::new(network) }`.
103 #[must_use]
104 pub fn new(network: Network) -> Self {
105 Self {
106 network,
107 api_key: None,
108 connections_per_operator: None,
109 proxy: None,
110 storage: None,
111 }
112 }
113}
114
115/// Constructs an [`SdkContext`] from a `SdkContextConfig`.
116///
117/// The returned `Arc` is cheap to clone and can back many SDK instances,
118/// sharing their HTTP client and operator gRPC channels.
119// `async` with no `.await`, and `async_runtime = "tokio"`: building the shared
120// gRPC client starts a background connection task that needs a tokio runtime,
121// so UniFFI must run this on its managed one.
122#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
123pub async fn new_shared_sdk_context(config: SdkContextConfig) -> Result<Arc<SdkContext>, SdkError> {
124 let user_agent = default_user_agent();
125 let proxy = config.proxy;
126 if let Some(proxy) = &proxy {
127 proxy.validate()?;
128 }
129 let transport_proxy = proxy.as_ref().map(platform_utils::ProxyConfig::from);
130 let http_client = create_http_client_with_proxy(Some(&user_agent), transport_proxy.as_ref())
131 .map_err(|e| SdkError::InvalidInput(format!("Failed to build proxied HTTP client: {e}")))?;
132 let breez_server = Arc::new(
133 BreezServer::new(
134 PRODUCTION_BREEZSERVER_URL,
135 None,
136 &user_agent,
137 transport_proxy.as_ref(),
138 )
139 .map_err(|e| SdkError::Generic(e.to_string()))?,
140 );
141 // The Breez partner JWT is only issued by the mainnet Breez endpoint, and
142 // only when an API key is configured. Skip the provider entirely otherwise
143 // — there is no token to fetch. SDKs sharing this context will share the
144 // one in-memory JWT and one background refresh task.
145 let api_key = config.api_key;
146 let jwt_header_provider = if matches!(config.network, Network::Mainnet)
147 && let Some(ref key) = api_key
148 {
149 Some(BreezJwtHeaderProvider::new(
150 key.clone(),
151 http_client.clone(),
152 ))
153 } else {
154 None
155 };
156 // SDKs that share the same context share the same gRPC channels to the
157 // Spark operators. `connections_per_operator` lets the rare deployment
158 // open multiple connections per operator and balance requests across
159 // them; `None` (or `Some(1)`) keeps a single multiplexed connection.
160 let connection_manager: Arc<dyn ConnectionManager> = match config.connections_per_operator {
161 Some(n) if n > 1 => {
162 // Balancing builds its own connectors, so there is nowhere to
163 // insert the SOCKS5 dialer. Refuse rather than open the extra
164 // connections unproxied.
165 if proxy.is_some() {
166 return Err(SdkError::InvalidInput(
167 "A proxy cannot be combined with connections_per_operator > 1: balanced \
168 operator connections cannot be routed through a proxy."
169 .to_string(),
170 ));
171 }
172 Arc::new(BalancedConnectionManager::new(n))
173 }
174 _ => Arc::new(DefaultConnectionManager::with_proxy(transport_proxy)),
175 };
176
177 // Every SDK built from this context shares the one storage backend (and
178 // its database connection pool).
179 let storage_backend = config.storage;
180
181 Ok(Arc::new(SdkContext {
182 http_client,
183 breez_server,
184 jwt_header_provider,
185 network: config.network,
186 api_key,
187 connection_manager,
188 proxy,
189 storage_backend,
190 }))
191}
192
193#[cfg(all(test, not(target_family = "wasm")))]
194mod tests {
195 use super::*;
196
197 #[tokio::test]
198 async fn default_config_yields_context_with_shared_clients_and_no_db() {
199 let ctx = new_shared_sdk_context(SdkContextConfig::new(Network::Regtest))
200 .await
201 .expect("default context");
202 // Just confirming the Arcs are non-null.
203 let _http = Arc::clone(&ctx.http_client);
204 let _breez = Arc::clone(&ctx.breez_server);
205 let _so = Arc::clone(&ctx.connection_manager);
206 // Default config has no api_key, so no JWT provider is constructed.
207 assert!(ctx.jwt_header_provider.is_none());
208 // Network and api_key are stored verbatim for the builder cross-check.
209 assert_eq!(ctx.network, Network::Regtest);
210 assert!(ctx.api_key.is_none());
211 assert!(ctx.storage_backend.is_none());
212 }
213
214 #[tokio::test]
215 async fn mainnet_with_api_key_constructs_jwt_provider_and_stores_inputs() {
216 let ctx = new_shared_sdk_context(SdkContextConfig {
217 api_key: Some("test-key".to_string()),
218 ..SdkContextConfig::new(Network::Mainnet)
219 })
220 .await
221 .expect("mainnet context");
222 assert!(ctx.jwt_header_provider.is_some());
223 assert_eq!(ctx.network, Network::Mainnet);
224 assert_eq!(ctx.api_key.as_deref(), Some("test-key"));
225 }
226
227 #[tokio::test]
228 async fn regtest_with_api_key_skips_jwt_but_still_stores_inputs() {
229 let ctx = new_shared_sdk_context(SdkContextConfig {
230 api_key: Some("test-key".to_string()),
231 ..SdkContextConfig::new(Network::Regtest)
232 })
233 .await
234 .expect("regtest context");
235 // Regtest never gets a JWT provider — there's no Breez endpoint to
236 // mint a token. But the inputs are still stored so the builder
237 // cross-check can detect a network mismatch.
238 assert!(ctx.jwt_header_provider.is_none());
239 assert_eq!(ctx.network, Network::Regtest);
240 assert_eq!(ctx.api_key.as_deref(), Some("test-key"));
241 }
242}