1use std::sync::{
14 Arc, OnceLock,
15 atomic::{AtomicBool, Ordering},
16};
17
18use parking_lot::RwLock;
19use prost::Message;
20use xmtp_api::{ApiClientWrapper, ApiError};
21use xmtp_configuration::{
22 BACKEND_DEFAULT_MAX_UPLOAD_BYTES, ConfigProvider, ServerConfiguration,
23 ServerConfigurationError, StoredConfigProvider,
24 attachments::{check_base_url, check_max_upload_bytes, check_retention_seconds},
25};
26use xmtp_db::prelude::*;
27use xmtp_db::{StorageError, server_configuration::StoredServerConfiguration};
28use xmtp_events::{ClientEvent, ClientRejectedByServer, EventWriter, RejectionCause};
29use xmtp_proto::api_client::XmtpBackendClient;
30use xmtp_proto::backend_v1;
31
32use crate::client::ClientError;
33
34#[derive(Debug, thiserror::Error)]
39pub enum ConfigurationFetchError {
40 #[error("the deployment record could not be written: {0}")]
41 StorageLocation(#[from] crate::storage_location::StorageLocationError),
42 #[error("the backend did not serve its configuration: {0}")]
45 Api(#[from] ApiError),
46 #[error("the fetched configuration could not be stored: {0}")]
48 Storage(#[from] StorageError),
49}
50
51impl xmtp_common::RetryableError for ConfigurationFetchError {
52 fn is_retryable(&self) -> bool {
53 match self {
54 Self::Api(e) => e.is_retryable(),
55 Self::Storage(e) => e.is_retryable(),
56 Self::StorageLocation(_) => false,
57 }
58 }
59}
60
61#[derive(Clone, Debug, PartialEq, Eq)]
64pub enum BlockedConnection {
65 BackendMismatch { stored: String, received: String },
67 ClientVersionTooOld { client: String, minimum: String },
69}
70
71impl From<&BlockedConnection> for ClientError {
72 fn from(blocked_connection: &BlockedConnection) -> Self {
73 match blocked_connection {
74 BlockedConnection::BackendMismatch { stored, received } => {
75 ClientError::BackendMismatch {
76 stored: stored.clone(),
77 received: received.clone(),
78 }
79 }
80 BlockedConnection::ClientVersionTooOld { client, minimum } => {
81 ClientError::ClientVersionTooOld {
82 client: client.clone(),
83 minimum: minimum.clone(),
84 }
85 }
86 }
87 }
88}
89
90#[derive(Clone)]
95pub struct ServerConfigurationHandle {
96 admission: Arc<tokio::sync::Mutex<()>>,
97 pending: Arc<AtomicBool>,
98 fetch: Arc<OnceLock<Arc<dyn xmtp_api::preflight::ConfigurationFetch>>>,
99 deployment_recorder: Arc<RwLock<Option<crate::storage_location::DeploymentRecorder>>>,
100 provider: Arc<dyn ConfigProvider>,
101 blocked_connection: Arc<RwLock<Option<BlockedConnection>>>,
102 event_writer: Arc<RwLock<Option<Arc<dyn EventWriter<()>>>>>,
103 restricted_chains: Option<Arc<[String]>>,
106}
107
108impl std::fmt::Debug for ServerConfigurationHandle {
109 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110 f.debug_struct("ServerConfigurationHandle")
111 .field("identifier", &self.configuration().identifier)
112 .field("blocked_connection", &*self.blocked_connection.read())
113 .finish()
114 }
115}
116
117impl Default for ServerConfigurationHandle {
118 fn default() -> Self {
119 Self::new(Arc::new(StoredConfigProvider::default()))
120 }
121}
122
123impl ServerConfigurationHandle {
124 #[cfg(any(test, feature = "test-utils"))]
125 pub(crate) fn custom_verifier(&self) -> bool {
126 self.restricted_chains.is_none()
127 }
128
129 pub(crate) fn requires_preflight(&self) -> bool {
130 self.pending.load(Ordering::Acquire)
131 }
132
133 pub fn new(provider: Arc<dyn ConfigProvider>) -> Self {
144 let sanitized = {
145 let supplied = provider.server_configuration();
146 let limits = supplied.limits.without_zeroes();
147 let attachments = supplied.attachments.as_ref().and_then(|offer| {
148 let mut offer = offer.clone();
149 if offer.max_upload_bytes == 0 {
150 offer.max_upload_bytes = BACKEND_DEFAULT_MAX_UPLOAD_BYTES;
151 }
152 let checked = check_base_url(&offer.base_url)
153 .and_then(|_| check_max_upload_bytes(offer.max_upload_bytes))
154 .and_then(|_| check_retention_seconds(offer.retention_seconds));
155 if let Err(reason) = checked {
156 tracing::warn!(
157 field = reason.field(),
158 reason = reason.reason(),
159 "ignoring unusable attachment storage offer"
160 );
161 None
162 } else {
163 Some(offer)
164 }
165 });
166 (limits != supplied.limits || attachments != supplied.attachments).then(|| {
167 ServerConfiguration {
168 limits,
169 attachments,
170 ..supplied.clone()
171 }
172 })
173 };
174 Self {
175 deployment_recorder: Arc::default(),
176 provider: match sanitized {
177 Some(configuration) => Arc::new(StoredConfigProvider::new(configuration)),
178 None => provider,
179 },
180 blocked_connection: Arc::default(),
181 event_writer: Arc::default(),
182 restricted_chains: None,
183 admission: Arc::default(),
184 pending: Arc::default(),
185 fetch: Arc::default(),
186 }
187 }
188
189 #[cfg(any(test, feature = "test-utils"))]
191 pub(crate) fn with_application_components(
192 mut self,
193 components: Vec<xmtp_configuration::ApplicationComponentDefinition>,
194 ) -> Self {
195 let mut configuration = self.configuration().clone();
196 configuration.application_components = components;
197 self.provider = Arc::new(StoredConfigProvider::new(configuration));
198 self
199 }
200
201 pub(crate) fn with_chain_restriction(mut self, custom_verifier: bool) -> Self {
205 self.restricted_chains = (!custom_verifier).then(|| {
206 Arc::<[String]>::from(self.configuration().smart_contract_wallet_chains.clone())
207 });
208 self
209 }
210
211 pub fn restrict(&self, request: &mut xmtp_id::associations::builder::SignatureRequest) {
214 if let Some(chains) = self.restricted_chains.clone() {
215 request.restrict_chains(chains);
216 }
217 }
218
219 pub fn configuration(&self) -> &ServerConfiguration {
222 self.provider.server_configuration()
223 }
224
225 pub fn commit_log_enabled(&self) -> bool {
229 self.configuration().mls.commit_log_enabled()
230 }
231
232 pub fn blocked_connection(&self) -> Option<BlockedConnection> {
234 self.blocked_connection.read().clone()
235 }
236
237 pub(crate) fn set_event_writer(&self, writer: Arc<dyn EventWriter<()>>) {
238 *self.event_writer.write() = Some(writer);
239 }
240
241 pub(crate) fn set_deployment_recorder(
242 &self,
243 recorder: crate::storage_location::DeploymentRecorder,
244 ) {
245 *self.deployment_recorder.write() = Some(recorder);
246 }
247
248 async fn record_deployment(&self, identifier: &str) -> Result<(), ClientError> {
249 let recorder = self.deployment_recorder.read().clone();
250 if let Some(recorder) = recorder {
251 recorder.record(identifier).await.map_err(|error| {
252 ClientError::ConfigurationUnavailable(Box::new(
253 ConfigurationFetchError::StorageLocation(error),
254 ))
255 })?;
256 }
257 Ok(())
258 }
259
260 pub fn check(&self) -> Result<(), ClientError> {
264 match self.blocked_connection.read().as_ref() {
265 Some(blocked_connection) => Err(blocked_connection.into()),
266 None => Ok(()),
267 }
268 }
269
270 pub(crate) fn blocked_or<E: From<ClientError>>(&self, error: E) -> E {
276 match self.check() {
277 Err(blocked) => blocked.into(),
278 Ok(()) => error,
279 }
280 }
281
282 pub(crate) fn block_connection(&self, reason: BlockedConnection) -> ClientError {
285 let mut guard = self.blocked_connection.write();
286 if let Some(held) = guard.as_ref() {
287 return held.into();
288 }
289 let error = ClientError::from(&reason);
290 let event = match &reason {
291 BlockedConnection::BackendMismatch { .. } => ClientRejectedByServer {
292 cause: RejectionCause::BackendMismatch,
293 min_libxmtp_version: None,
294 },
295 BlockedConnection::ClientVersionTooOld { minimum, .. } => ClientRejectedByServer {
296 cause: RejectionCause::VersionTooOld,
297 min_libxmtp_version: Some(minimum.clone()),
298 },
299 };
300 *guard = Some(reason);
301 drop(guard);
302 if let Some(writer) = self.event_writer.read().as_ref() {
303 writer.emit(Some(ClientEvent::ClientRejectedByServer(event)), None);
304 }
305 error
306 }
307}
308
309fn snapshot_from(stored: &StoredServerConfiguration) -> ServerConfiguration {
315 match backend_v1::GetConfigurationResponse::decode(stored.response.as_slice()) {
316 Ok(response) => ServerConfiguration::from(response),
317 Err(error) => {
318 tracing::warn!(
319 identifier = %stored.identifier,
320 %error,
321 "stored server configuration does not decode; using compiled defaults"
322 );
323 ServerConfiguration {
324 identifier: stored.identifier.clone(),
325 ..Default::default()
326 }
327 }
328 }
329}
330
331pub(crate) fn validated(
333 response: &backend_v1::GetConfigurationResponse,
334) -> Result<ServerConfiguration, ServerConfigurationError> {
335 let configuration = ServerConfiguration::from(response.clone());
336 configuration.validate()?;
337 Ok(configuration)
338}
339
340pub(crate) async fn fetch_and_store<ApiClient>(
346 api: &ApiClientWrapper<ApiClient>,
347 db: &impl DbQuery,
348 handle: &ServerConfigurationHandle,
349) -> Result<ServerConfiguration, ClientError>
350where
351 ApiClient: XmtpBackendClient,
352{
353 handle.check()?;
354
355 let response = api.get_configuration().await.map_err(|error| {
356 ClientError::ConfigurationUnavailable(Box::new(ConfigurationFetchError::Api(error)))
357 })?;
358 let configuration = commit_configuration(response, api.backend_url(), db, handle)?;
359 handle.record_deployment(&configuration.identifier).await?;
360 Ok(configuration)
361}
362
363fn commit_configuration(
364 response: backend_v1::GetConfigurationResponse,
365 backend_url: Option<&str>,
366 db: &impl DbQuery,
367 handle: &ServerConfigurationHandle,
368) -> Result<ServerConfiguration, ClientError> {
369 let configuration = validated(&response)?;
370
371 let stored = db.server_configuration().map_err(storage_unavailable)?;
374 if let Some(stored) = stored.as_ref()
375 && !stored.identifier.is_empty()
376 && stored.identifier != configuration.identifier
377 {
378 tracing::error!(
379 stored = %stored.identifier,
380 received = %configuration.identifier,
381 "this database is bound to a different backend deployment"
382 );
383 if let Err(error) = db.record_server_configuration_conflict(&configuration.identifier) {
386 tracing::error!(%error, "could not record the conflicting backend identifier");
387 }
388 return Err(handle.block_connection(BlockedConnection::BackendMismatch {
389 stored: stored.identifier.clone(),
390 received: configuration.identifier.clone(),
391 }));
392 }
393
394 db.store_server_configuration(
395 &configuration.identifier,
396 normalized_url(backend_url.unwrap_or_default()),
397 &response.encode_to_vec(),
398 xmtp_common::time::now_ns(),
399 )
400 .map_err(storage_unavailable)?;
401
402 Ok(configuration)
403}
404
405pub async fn fetch_server_configuration<ApiClient>(
412 api: &ApiClientWrapper<ApiClient>,
413) -> Result<ServerConfiguration, ClientError>
414where
415 ApiClient: XmtpBackendClient,
416{
417 let response = api.get_configuration().await.map_err(|error| {
418 ClientError::ConfigurationUnavailable(Box::new(ConfigurationFetchError::Api(error)))
419 })?;
420 Ok(validated(&response)?)
421}
422
423pub(crate) fn normalized_url(url: &str) -> &str {
429 url.trim_end_matches('/')
430}
431
432fn storage_unavailable(error: StorageError) -> ClientError {
433 ClientError::ConfigurationUnavailable(Box::new(ConfigurationFetchError::Storage(error)))
434}
435
436pub(crate) async fn resolve<ApiClient>(
446 api: &ApiClientWrapper<ApiClient>,
447 db: &impl DbQuery,
448 allow_offline: bool,
449) -> Result<ServerConfigurationHandle, ClientError>
450where
451 ApiClient: XmtpBackendClient,
452{
453 let stored = db.server_configuration().map_err(storage_unavailable)?;
454
455 if let Some(conflicting) = stored
458 .as_ref()
459 .and_then(|row| row.conflicting_identifier.clone())
460 {
461 return Err(ClientError::BackendMismatch {
462 stored: stored.map(|row| row.identifier).unwrap_or_default(),
463 received: conflicting,
464 });
465 }
466
467 let pending = allow_offline
468 && stored.as_ref().is_some_and(|stored| {
469 api.backend_url()
470 .is_some_and(|url| normalized_url(url) != normalized_url(&stored.backend_url))
471 });
472 let configuration = match (allow_offline, stored) {
473 (true, None) => ServerConfiguration::default(),
476 (true, Some(stored)) => snapshot_from(&stored),
478 (false, None) => {
480 let handle = ServerConfigurationHandle::default();
481 fetch_and_store(api, db, &handle).await?
482 }
483 (false, Some(stored)) => {
484 let moved = api
488 .backend_url()
489 .is_some_and(|url| normalized_url(url) != normalized_url(&stored.backend_url));
490 if moved {
491 tracing::info!(
492 from = %stored.backend_url,
493 to = api.backend_url().unwrap_or_default(),
494 "backend URL changed; re-reading the deployment configuration"
495 );
496 let handle = ServerConfigurationHandle::default();
497 fetch_and_store(api, db, &handle).await?
498 } else {
499 snapshot_from(&stored)
500 }
501 }
502 };
503
504 let mut handle =
505 ServerConfigurationHandle::new(Arc::new(StoredConfigProvider::new(configuration)));
506 handle.pending = Arc::new(AtomicBool::new(pending));
507 Ok(handle)
508}
509
510pub(crate) fn check_minimum_version(
514 configuration: &ServerConfiguration,
515 client_version: &semver::Version,
516) -> Result<(), ClientError> {
517 let Some(minimum) = configuration.minimum_version()? else {
518 return Ok(());
519 };
520 if xmtp_configuration::version_is_below(client_version, &minimum) {
521 return Err(ClientError::ClientVersionTooOld {
522 client: client_version.to_string(),
523 minimum: minimum.to_string(),
524 });
525 }
526 Ok(())
527}
528
529pub mod worker;
530
531#[cfg(test)]
532mod tests;
533
534mod preflight;
535pub(crate) use preflight::{bind_preflight, refresh};