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