Skip to main content

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}