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};
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}