Skip to main content

xmtp_mls/
builder.rs

1#[cfg(test)]
2use crate::GroupCommitLock;
3use crate::{
4    StorageError, XmtpApi,
5    client::{Client, ClientError, DeviceSync},
6    context::{XmtpMlsLocalContext, XmtpSharedContext},
7    groups::change_callbacks::UnstableChangeCallbacks,
8    identity::{Identity, IdentityStrategy},
9    identity_updates::load_identity_updates,
10    mutex_registry::MutexRegistry,
11    server_configuration::ServerConfigurationHandle,
12    utils::{VersionInfo, cleanup_duplicate_updates},
13    worker::{WorkerRunner, tasks::TaskWorker},
14    worker::{device_sync::worker::SyncWorker, disappearing_messages::DisappearingMessagesWorker},
15};
16use futures::FutureExt;
17use std::sync::Arc;
18use std::sync::atomic::AtomicBool;
19use thiserror::Error;
20use tokio::sync::broadcast;
21use tokio_util::sync::CancellationToken;
22use tracing::debug;
23use xmtp_api::ApiClientWrapper;
24use xmtp_api_backend::TrackedStatsClient;
25use xmtp_common::{ErrorCode, Event, Retry};
26use xmtp_cryptography::signature::IdentifierValidationError;
27use xmtp_db::{DbConnection, XmtpMlsStorageProvider, prelude::*};
28use xmtp_db::{XmtpDb, sql_key_store::SqlKeyStore};
29use xmtp_id::scw_verifier::SmartContractSignatureVerifier;
30use xmtp_macro::log_event;
31use xmtp_proto::xmtp::mls::database::{
32    ProcessPendingSelfRemove, Task as TaskProto, task::Task as TaskKind,
33};
34
35type ContextParts<Api, S, Db> = Arc<XmtpMlsLocalContext<Api, Db, S>>;
36
37#[derive(Error, Debug, ErrorCode)]
38pub enum ClientBuilderError {
39    #[error(transparent)]
40    #[error_code(inherit)]
41    AddressValidation(#[from] IdentifierValidationError),
42    /// Missing parameter.
43    ///
44    /// Required builder parameter not provided. Not retryable.
45    #[error("Missing parameter: {parameter}")]
46    MissingParameter { parameter: &'static str },
47    /// Client error.
48    ///
49    /// Client operation failed during build. May be retryable.
50    #[error(transparent)]
51    ClientError(#[from] crate::client::ClientError),
52    /// Storage error.
53    ///
54    /// Storage initialization failed. Not retryable.
55    #[error("Storage Error")]
56    StorageError(#[from] StorageError),
57    /// Identity error.
58    ///
59    /// Identity creation/loading failed. Not retryable.
60    #[error(transparent)]
61    Identity(#[from] crate::identity::IdentityError),
62    /// API error.
63    ///
64    /// API client initialization failed. Retryable.
65    #[error(transparent)]
66    WrappedApiError(#[from] xmtp_api::ApiError),
67    /// Group error.
68    ///
69    /// Group operation failed during build. Not retryable.
70    #[error(transparent)]
71    GroupError(#[from] Box<crate::groups::GroupError>),
72    /// Device sync error.
73    ///
74    /// Device sync setup failed. Not retryable.
75    #[error(transparent)]
76    DeviceSync(#[from] Box<crate::worker::device_sync::DeviceSyncError>),
77    /// Offline build failed.
78    ///
79    /// Builder tried to access the network in offline mode. Not retryable.
80    #[error("Offline build failed, builder tried to access the network")]
81    OfflineBuildFailed,
82}
83
84impl From<crate::worker::device_sync::DeviceSyncError> for ClientBuilderError {
85    fn from(value: crate::worker::device_sync::DeviceSyncError) -> Self {
86        ClientBuilderError::DeviceSync(Box::new(value))
87    }
88}
89
90impl From<crate::groups::GroupError> for ClientBuilderError {
91    fn from(value: crate::groups::GroupError) -> Self {
92        ClientBuilderError::GroupError(Box::new(value))
93    }
94}
95
96pub struct ClientBuilder<ApiClient, S, Db = xmtp_db::DefaultStore> {
97    pub(crate) api_client: Option<ApiClient>,
98    pub(crate) identity: Option<Identity>,
99    pub(crate) store: Option<Db>,
100    pub(crate) identity_strategy: IdentityStrategy,
101    pub(crate) scw_verifier: Option<Box<dyn SmartContractSignatureVerifier>>,
102    /// Whether the app supplied its own verifier, which CFG-069 exempts from
103    /// the chain check.
104    pub(crate) custom_scw_verifier: bool,
105    pub(crate) device_sync_worker_mode: DeviceSyncMode,
106    pub(crate) fork_recovery_opts: Option<ForkRecoveryOpts>,
107    /// Unstable: group-change callbacks the host registered at construction.
108    pub(crate) change_callbacks: UnstableChangeCallbacks,
109    pub(crate) stream_policy: crate::subscriptions::policy::StreamPolicy,
110    pub(crate) incoming_factory:
111        Option<Arc<dyn crate::subscriptions::incoming::SubscriptionFactory>>,
112    pub(crate) version_info: VersionInfo,
113    pub(crate) allow_offline: bool,
114    pub(crate) disable_commit_log_worker: bool,
115    pub(crate) mls_storage: Option<S>,
116    pub(crate) disable_workers: bool,
117    pub(crate) worker_config: crate::worker::WorkerConfig,
118    /// CFG-033: a snapshot supplied by the caller. When present the client
119    /// never fetches, stores, refreshes, or checks the identifier. Rust tests
120    /// only; not exposed through the bindings.
121    pub(crate) config_provider: Option<Arc<dyn xmtp_configuration::ConfigProvider>>,
122}
123
124#[derive(Clone, Copy, Debug, PartialEq, Eq)]
125pub enum DeviceSyncMode {
126    Disabled,
127    Enabled,
128}
129
130#[derive(Clone, Debug, PartialEq)]
131pub enum ForkRecoveryPolicy {
132    None,
133    AllowlistedGroups,
134    All,
135}
136
137#[derive(Clone, Debug)]
138pub struct ForkRecoveryOpts {
139    pub enable_recovery_requests: ForkRecoveryPolicy,
140    pub groups_to_request_recovery: Vec<String>,
141    pub disable_recovery_responses: bool,
142    pub worker_interval_ns: Option<u64>,
143}
144
145impl Default for ForkRecoveryOpts {
146    fn default() -> Self {
147        Self {
148            enable_recovery_requests: ForkRecoveryPolicy::None,
149            groups_to_request_recovery: Vec::new(),
150            disable_recovery_responses: false,
151            worker_interval_ns: None,
152        }
153    }
154}
155
156impl Client<()> {
157    /// Get the builder for this [`Client`]
158    pub fn builder(strategy: IdentityStrategy) -> ClientBuilder<(), ()> {
159        ClientBuilder::<(), ()>::new(strategy)
160    }
161}
162
163impl<ApiClient, S, Db> ClientBuilder<ApiClient, S, Db> {
164    /// Override internal limits for controlled tests.
165    #[cfg(test)]
166    pub(crate) fn stream_policy(
167        mut self,
168        settings: crate::subscriptions::policy::StreamPolicy,
169    ) -> Self {
170        self.stream_policy = settings;
171        self
172    }
173
174    #[tracing::instrument(level = "trace", skip_all)]
175    pub fn new(identity_strategy: IdentityStrategy) -> Self {
176        Self {
177            identity_strategy,
178            api_client: None,
179            identity: None,
180            store: None,
181            scw_verifier: None,
182            custom_scw_verifier: false,
183            device_sync_worker_mode: DeviceSyncMode::Enabled,
184            fork_recovery_opts: None,
185            change_callbacks: UnstableChangeCallbacks::default(),
186            stream_policy: crate::subscriptions::policy::StreamPolicy::default(),
187            incoming_factory: None,
188            version_info: VersionInfo::default(),
189            allow_offline: false,
190            disable_commit_log_worker: false,
191            mls_storage: None,
192            disable_workers: false,
193            worker_config: crate::worker::WorkerConfig::default(),
194            config_provider: None,
195        }
196    }
197}
198
199#[cfg(test)]
200impl<ApiClient, S, Db> ClientBuilder<ApiClient, S, Db>
201where
202    ApiClient: Clone,
203    Db: Clone,
204    S: Clone,
205{
206    pub fn from_client(
207        client: Client<ContextParts<ApiClient, S, Db>>,
208    ) -> ClientBuilder<ApiClient, S, Db> {
209        let cloned_api: ApiClient = client.context.api_client.clone().api_client;
210        ClientBuilder {
211            api_client: Some(cloned_api),
212            identity: Some(client.context.identity.clone()),
213            store: Some(client.context.store.clone()),
214            identity_strategy: IdentityStrategy::CachedOnly,
215            scw_verifier: Some(Box::new(client.context.scw_verifier.clone())),
216            custom_scw_verifier: false,
217            device_sync_worker_mode: client.context.device_sync.mode,
218            fork_recovery_opts: Some(client.context.fork_recovery_opts.clone()),
219            change_callbacks: client.context.change_callbacks.clone(),
220            stream_policy: client.context.incoming_runtime.policy().clone(),
221            incoming_factory: client.context.incoming_runtime.factory.clone(),
222            version_info: client.context.version_info.clone(),
223            allow_offline: false,
224            disable_commit_log_worker: false,
225            mls_storage: Some(client.context.mls_storage.clone()),
226            disable_workers: false,
227            worker_config: client.context.worker_config.clone(),
228            config_provider: None,
229        }
230    }
231}
232
233/// One-time backfill of `ProcessPendingSelfRemove` tasks for groups that already
234/// had pending leave requests before self-removal became event-driven. Such rows
235/// have no incoming LeaveRequest to re-fire, so without this they'd only be
236/// processed if a new one arrived. Best-effort and idempotent (deduped per group).
237///
238/// TODO(#3748): removable once every client has shipped a release that enqueues
239/// these tasks inline — safe to delete by the next-next stable release.
240fn backfill_pending_self_remove_tasks<C>(context: &C) -> Result<(), StorageError>
241where
242    C: XmtpSharedContext,
243{
244    let db = context.db();
245    for raw_id in db.get_groups_have_pending_leave_request()? {
246        let Ok(group_id) = xmtp_proto::types::GroupId::try_from(raw_id.as_slice()) else {
247            continue;
248        };
249        let now = xmtp_common::time::now_ns();
250        let proto = TaskProto {
251            task: Some(TaskKind::ProcessPendingSelfRemove(
252                ProcessPendingSelfRemove {
253                    group_id: group_id.to_vec(),
254                },
255            )),
256        };
257        let task = xmtp_db::tasks::NewTask::builder()
258            .originating_message_sequence_id(0)
259            .created_at_ns(now)
260            .next_attempt_at_ns(now)
261            .build(proto)?;
262        // Insert-if-absent per group: leaves any live retrying task (and its
263        // backoff) untouched, only replacing dead rows. Safe to call on every
264        // startup without resurrecting exhausted tasks.
265        db.upsert_pending_self_remove_task(&group_id, task)?;
266    }
267    Ok(())
268}
269
270// TODO: the return type is temp and
271// will be modified in subsequent PRs
272impl<ApiClient, S, Db> ClientBuilder<ApiClient, S, Db> {
273    /// build a client in offline mode.
274    /// returns an error if the client failed to build as offline
275    pub fn build_offline(self) -> Result<Client<ContextParts<ApiClient, S, Db>>, ClientBuilderError>
276    where
277        ApiClient: XmtpApi + 'static,
278        Db: xmtp_db::XmtpDb + 'static,
279        S: XmtpMlsStorageProvider + 'static,
280    {
281        self.build()
282            .now_or_never()
283            .ok_or(ClientBuilderError::OfflineBuildFailed)
284            .flatten()
285    }
286
287    #[tracing::instrument(err, skip_all, fields(operation = "mls.build_client"))]
288    pub async fn build(self) -> Result<Client<ContextParts<ApiClient, S, Db>>, ClientBuilderError>
289    where
290        ApiClient: XmtpApi + 'static,
291        Db: xmtp_db::XmtpDb + 'static,
292        S: XmtpMlsStorageProvider + 'static,
293    {
294        let ClientBuilder {
295            mut api_client,
296            identity,
297            mut store,
298            identity_strategy,
299            mut scw_verifier,
300            custom_scw_verifier,
301
302            device_sync_worker_mode,
303            fork_recovery_opts,
304            change_callbacks,
305            stream_policy,
306            incoming_factory,
307            version_info,
308            allow_offline,
309            disable_commit_log_worker,
310            mut mls_storage,
311            disable_workers,
312            worker_config,
313            config_provider,
314            ..
315        } = self;
316
317        let api_client = api_client
318            .take()
319            .ok_or(ClientBuilderError::MissingParameter {
320                parameter: "api_client",
321            })?;
322
323        let scw_verifier = scw_verifier
324            .take()
325            .ok_or(ClientBuilderError::MissingParameter {
326                parameter: "scw_verifier",
327            })?;
328
329        let store = store
330            .take()
331            .ok_or(ClientBuilderError::MissingParameter { parameter: "store" })?;
332
333        let mls_storage = mls_storage
334            .take()
335            .ok_or(ClientBuilderError::MissingParameter {
336                parameter: "mls_storage",
337            })?;
338
339        let mut api_client = ApiClientWrapper::new(api_client, Retry::default());
340        let conn = store.db();
341
342        // Spec 006 §6.2: the configuration is resolved before any identity
343        // work, so a deployment that refuses this client refuses it before the
344        // database gains an identity. A caller-supplied provider (CFG-033)
345        // short-circuits every network and database path here, which is what
346        // keeps `build_offline` free of a pending future.
347        let has_config_provider = config_provider.is_some();
348        let server_configuration = match config_provider {
349            Some(provider) => ServerConfigurationHandle::new(provider),
350            None => crate::server_configuration::resolve(&api_client, &conn, allow_offline).await?,
351        };
352
353        let server_configuration = server_configuration.with_chain_restriction(custom_scw_verifier);
354
355        // CFG-060: a deployment that requires a newer client refuses this build,
356        // whether the snapshot came from the backend or from a provider.
357        crate::server_configuration::check_minimum_version(
358            server_configuration.configuration(),
359            version_info.pkg_semver().semver(),
360        )?;
361
362        // CFG-062: a deployment that requires a credential refuses a client
363        // that has no way to produce one.
364        let configuration = server_configuration.configuration();
365        if configuration.auth.enabled && !api_client.has_credential_source() {
366            return Err(ClientBuilderError::ClientError(ClientError::AuthRequired {
367                required_scopes: configuration.auth.required_scopes.clone(),
368            }));
369        }
370
371        // CFG-064 and CFG-065: install the snapshot before any request is made,
372        // so even the identity work below chunks and pre-validates against the
373        // shapes this deployment publishes. The transport is told separately,
374        // because stream and interest-update chunking happens below the
375        // wrapper and never sees the wrapper's copy.
376        let snapshot = Arc::new(configuration.clone());
377        api_client
378            .api_client
379            .set_limits(Arc::new(snapshot.limits.clone()));
380        api_client.set_configuration(snapshot);
381
382        let mut identity = if let Some(identity) = identity {
383            identity
384        } else {
385            identity_strategy
386                .initialize_identity(&api_client, &mls_storage, &scw_verifier)
387                .await?
388        };
389
390        // CFG-069 and CFG-070: the registration request is handed to the app to
391        // sign, so bind it to the chains the deployment accepts before it
392        // leaves the client.
393        if let Some(request) = identity.signature_request.as_mut() {
394            server_configuration.restrict(request);
395        }
396
397        debug!(
398            inbox_id = identity.inbox_id(),
399            installation_id = hex::encode(identity.installation_keys.public_bytes()),
400            "Initialized identity"
401        );
402        if !allow_offline {
403            // get sequence_id from identity updates and loaded into the DB
404            load_identity_updates(
405                &api_client,
406                &conn,
407                vec![identity.inbox_id.as_str()].as_slice(),
408            )
409            .await?;
410        }
411
412        // Fold the legacy single-worker toggles into the unified enable map so
413        // there is one source of truth for "is worker X enabled" that code paths
414        // (e.g. the disappearing-message store site) can consult before nudging a
415        // worker's channel. The old fields keep working; they just write here.
416        let mut worker_config = worker_config;
417        if disable_workers {
418            // Global kill-switch: nothing runs, so mark every worker disabled.
419            for kind in [
420                crate::worker::WorkerKind::DeviceSync,
421                crate::worker::WorkerKind::DisappearingMessages,
422                crate::worker::WorkerKind::CommitLog,
423                crate::worker::WorkerKind::TaskRunner,
424                crate::worker::WorkerKind::ConfigurationRefresh,
425            ] {
426                worker_config.enabled.insert(kind, false);
427            }
428        }
429        if matches!(device_sync_worker_mode, DeviceSyncMode::Disabled) {
430            worker_config
431                .enabled
432                .entry(crate::worker::WorkerKind::DeviceSync)
433                .or_insert(false);
434        }
435        if disable_commit_log_worker {
436            worker_config
437                .enabled
438                .entry(crate::worker::WorkerKind::CommitLog)
439                .or_insert(false);
440        }
441
442        let (local_events, _) = broadcast::channel(32);
443        let (worker_tx, _) = broadcast::channel(32);
444        let mut workers = WorkerRunner::new();
445        let context = Arc::new(XmtpMlsLocalContext {
446            identity,
447            mls_storage,
448            store,
449            api_client,
450            version_info,
451            server_configuration,
452            scw_verifier: Arc::new(scw_verifier),
453            mutexes: MutexRegistry::new(),
454            #[cfg(test)]
455            mls_commit_lock: Arc::new(GroupCommitLock::new()),
456            local_events: local_events.clone(),
457            worker_events: worker_tx.clone(),
458            device_sync: DeviceSync {
459                mode: device_sync_worker_mode,
460            },
461            fork_recovery_opts: fork_recovery_opts.unwrap_or_default(),
462            change_callbacks,
463            incoming_runtime: Arc::new(crate::subscriptions::incoming::IncomingRuntime::new(
464                stream_policy,
465                incoming_factory,
466            )),
467            worker_config,
468
469            worker_metrics: workers.metrics().clone(),
470            task_channels: workers.task_channels().clone(),
471            disappearing_channels: crate::worker::disappearing_messages::DisappearingChannels::new(
472            ),
473            cancellation_token: CancellationToken::new(),
474            shutdown_complete: Arc::new(AtomicBool::new(false)),
475            delivery_owner: Default::default(),
476            identity_resolutions: Default::default(),
477        });
478
479        // register workers
480        if !disable_workers {
481            use crate::worker::WorkerKind;
482            // One source of truth for enablement: the folded WorkerConfig map.
483            let enabled = |k| context.worker_config().worker_enabled(k);
484
485            // Keep the original `device_sync_worker_enabled()` AND-check to
486            // preserve the exact legacy semantics alongside the map gate.
487            if enabled(WorkerKind::DeviceSync) && context.device_sync_worker_enabled() {
488                workers.register_new_worker::<SyncWorker<ContextParts<ApiClient, S, Db>>, _>(
489                    context.clone(),
490                );
491            }
492            if enabled(WorkerKind::DisappearingMessages) {
493                workers
494                    .register_new_worker::<DisappearingMessagesWorker<ContextParts<ApiClient, S, Db>>, _>(
495                        context.clone(),
496                    );
497            }
498            // Enable CommitLogWorker based on configuration
499            // CFG-068: the deployment decides whether the commit log runs,
500            // falling back to the compiled default when it says nothing.
501            if enabled(WorkerKind::CommitLog)
502                && context
503                    .server_configuration()
504                    .configuration()
505                    .mls
506                    .commit_log_enabled()
507                && !disable_commit_log_worker
508            {
509                workers.register_new_worker::<
510                crate::groups::commit_log::CommitLogWorker<ContextParts<ApiClient, S, Db>>,
511                _,
512                >(context.clone());
513            }
514            // CFG-046: only a client that reads its configuration refreshes it.
515            // A caller-supplied provider (CFG-033) owns its own values.
516            if enabled(WorkerKind::ConfigurationRefresh) && !has_config_provider {
517                workers
518                    .register_new_worker::<crate::server_configuration::worker::ConfigurationWorker<
519                        ContextParts<ApiClient, S, Db>,
520                    >, _>(context.clone());
521            }
522            if enabled(WorkerKind::TaskRunner) {
523                workers.register_new_worker::<TaskWorker<ContextParts<ApiClient, S, Db>>, _>(
524                    context.clone(),
525                );
526                // KP maintenance is deliberately coupled to the TaskRunner (no
527                // standalone fallback): disabling the TaskRunner — per-kind via
528                // WorkerConfig or globally via disable_workers — disables KP
529                // rotation/deletion with it. Seeding failure is fatal to the
530                // build: the DB was already opened/migrated above, so an error
531                // here means it is broken; building a client whose critical
532                // maintenance silently never got seeded would be worse.
533                crate::worker::key_package_maintenance::seed_and_reconcile_kp_tasks(&context)?;
534                // One-time backfill: pending self-removes recorded before the
535                // worker became event-driven have no LeaveRequest to re-fire, so
536                // seed a ProcessPendingSelfRemove task for each already-flagged
537                // group. Best-effort (logged, never fails the build).
538                //
539                // TODO: remove this migration once all clients have shipped a
540                // release that enqueues these tasks inline — safe to delete by the
541                // next-next stable release.
542                if let Err(e) = backfill_pending_self_remove_tasks(&context) {
543                    tracing::warn!(
544                        "pending-self-remove backfill failed (will rely on next sync): {e}"
545                    );
546                }
547            }
548        }
549
550        let workers = Arc::new(workers);
551
552        if !disable_workers {
553            workers.spawn(context.clone());
554        }
555
556        log_event!(
557            Event::ClientCreated,
558            context.installation_id(),
559            inbox_id = context.inbox_id(),
560            full_installation_id = hex::encode(context.installation_id()),
561            device_sync_enabled = context.device_sync_worker_enabled(),
562            disabled_workers = disable_workers,
563        );
564
565        let installation_id = context.installation_id();
566        let client = Client {
567            context,
568            installation_id,
569            local_events,
570            workers,
571        };
572
573        // Cleanup old unstitched group updated messages.
574        let conn = DbConnection::new(client.db());
575        let cancel = client.context.cancellation_token().clone();
576        xmtp_common::spawn(None, async move {
577            tokio::select! {
578                _ = cancel.cancelled() => {}
579                _ = cleanup_duplicate_updates::perform(conn) => {}
580            }
581        });
582
583        Ok(client)
584    }
585
586    pub fn identity(self, identity: Identity) -> Self {
587        Self {
588            identity: Some(identity),
589            ..self
590        }
591    }
592
593    /// Unstable: register callbacks notified when group state changes.
594    ///
595    /// Registration is construction-time by necessity — the changes these
596    /// report arrive from the stream and sync paths, where no SDK call is on
597    /// the stack. Passing [`UnstableChangeCallbacks::default`] (nothing set)
598    /// is equivalent to not calling this at all.
599    ///
600    /// See [`crate::groups::change_callbacks`] for the delivery contract.
601    pub fn unstable_change_callbacks(self, change_callbacks: UnstableChangeCallbacks) -> Self {
602        Self {
603            change_callbacks,
604            ..self
605        }
606    }
607
608    pub fn store<NewDb>(self, db: NewDb) -> ClientBuilder<ApiClient, S, NewDb> {
609        ClientBuilder {
610            store: Some(db),
611            api_client: self.api_client,
612            identity: self.identity,
613            identity_strategy: self.identity_strategy,
614            scw_verifier: self.scw_verifier,
615            custom_scw_verifier: self.custom_scw_verifier,
616            device_sync_worker_mode: self.device_sync_worker_mode,
617            fork_recovery_opts: self.fork_recovery_opts,
618            change_callbacks: self.change_callbacks,
619            stream_policy: self.stream_policy,
620            incoming_factory: self.incoming_factory,
621            version_info: self.version_info,
622            allow_offline: self.allow_offline,
623            disable_commit_log_worker: self.disable_commit_log_worker,
624            mls_storage: self.mls_storage,
625            disable_workers: self.disable_workers,
626            worker_config: self.worker_config,
627            config_provider: self.config_provider,
628        }
629    }
630
631    /// Use the default SQlite MLS Key-Value Store
632    pub fn default_mls_store(
633        self,
634    ) -> Result<
635        ClientBuilder<ApiClient, SqlKeyStore<<Db as XmtpDb>::DbQuery>, Db>,
636        ClientBuilderError,
637    >
638    where
639        Db: XmtpDb,
640    {
641        Ok(ClientBuilder {
642            api_client: self.api_client,
643            identity: self.identity,
644            identity_strategy: self.identity_strategy,
645            scw_verifier: self.scw_verifier,
646            custom_scw_verifier: self.custom_scw_verifier,
647            device_sync_worker_mode: self.device_sync_worker_mode,
648            fork_recovery_opts: self.fork_recovery_opts,
649            change_callbacks: self.change_callbacks,
650            stream_policy: self.stream_policy,
651            incoming_factory: self.incoming_factory,
652            version_info: self.version_info,
653            allow_offline: self.allow_offline,
654            disable_commit_log_worker: self.disable_commit_log_worker,
655            mls_storage: Some(SqlKeyStore::new(
656                self.store
657                    .as_ref()
658                    .ok_or(ClientBuilderError::MissingParameter {
659                        parameter: "encrypted store",
660                    })?
661                    .db(),
662            )),
663            store: self.store,
664            disable_workers: self.disable_workers,
665            worker_config: self.worker_config,
666            config_provider: self.config_provider,
667        })
668    }
669
670    pub fn mls_storage<NewS>(self, mls_storage: NewS) -> ClientBuilder<ApiClient, NewS, Db> {
671        ClientBuilder {
672            store: self.store,
673            api_client: self.api_client,
674            identity: self.identity,
675            identity_strategy: self.identity_strategy,
676            scw_verifier: self.scw_verifier,
677            custom_scw_verifier: self.custom_scw_verifier,
678            device_sync_worker_mode: self.device_sync_worker_mode,
679            fork_recovery_opts: self.fork_recovery_opts,
680            change_callbacks: self.change_callbacks,
681            stream_policy: self.stream_policy,
682            incoming_factory: self.incoming_factory,
683            version_info: self.version_info,
684            allow_offline: self.allow_offline,
685            disable_commit_log_worker: self.disable_commit_log_worker,
686            mls_storage: Some(mls_storage),
687            disable_workers: self.disable_workers,
688            worker_config: self.worker_config,
689            config_provider: self.config_provider,
690        }
691    }
692
693    pub fn with_disable_workers(mut self, disable_workers: bool) -> Self {
694        self.disable_workers = disable_workers;
695        self
696    }
697
698    pub fn with_device_sync_worker_mode(self, mode: Option<DeviceSyncMode>) -> Self {
699        Self {
700            device_sync_worker_mode: mode.unwrap_or(DeviceSyncMode::Enabled),
701            ..self
702        }
703    }
704
705    pub fn device_sync_worker_mode(self, mode: DeviceSyncMode) -> Self {
706        Self {
707            device_sync_worker_mode: mode,
708            ..self
709        }
710    }
711
712    pub fn fork_recovery_opts(self, opts: ForkRecoveryOpts) -> Self {
713        Self {
714            fork_recovery_opts: Some(opts),
715            ..self
716        }
717    }
718
719    /// Configure background-worker intervals, jitter, and per-worker
720    /// enablement. See [`crate::worker::WorkerConfig`].
721    pub fn worker_config(mut self, cfg: crate::worker::WorkerConfig) -> Self {
722        self.worker_config = cfg;
723        self
724    }
725
726    /// Supply the server configuration instead of reading it (CFG-033).
727    ///
728    /// With a provider in place the client never fetches, stores, refreshes, or
729    /// checks the deployment identifier. Rust callers only — the bindings do
730    /// not expose this.
731    pub fn config_provider(
732        mut self,
733        provider: Arc<dyn xmtp_configuration::ConfigProvider>,
734    ) -> Self {
735        self.config_provider = Some(provider);
736        self
737    }
738
739    /// Attach a query-only API client. Receipt uses ordered Query pages.
740    /// Standard streaming clients use `api_client_with_streams` at construction.
741    pub fn api_client<A>(self, api_client: A) -> ClientBuilder<A, S, Db> {
742        ClientBuilder {
743            api_client: Some(api_client),
744            identity: self.identity,
745            identity_strategy: self.identity_strategy,
746            scw_verifier: self.scw_verifier,
747            custom_scw_verifier: self.custom_scw_verifier,
748            store: self.store,
749            device_sync_worker_mode: self.device_sync_worker_mode,
750            fork_recovery_opts: self.fork_recovery_opts,
751            change_callbacks: self.change_callbacks,
752            stream_policy: self.stream_policy,
753            incoming_factory: None,
754            version_info: self.version_info,
755            allow_offline: self.allow_offline,
756            disable_commit_log_worker: self.disable_commit_log_worker,
757            mls_storage: self.mls_storage,
758            disable_workers: self.disable_workers,
759            worker_config: self.worker_config,
760            config_provider: self.config_provider,
761        }
762    }
763
764    xmtp_common::if_native! {
765        /// Attach a native API client with a lazy shared Bidi receiver.
766        /// No transport task or connection starts until the first receiving interest.
767        pub fn api_client_with_streams<A>(self, api_client: A) -> ClientBuilder<A, S, Db>
768        where
769            A: xmtp_proto::api_client::XmtpMlsBidiStreams
770                + crate::subscriptions::router_callbacks::ApiClientIdentity
771                + Clone
772                + Send
773                + Sync
774                + 'static,
775            A::SubscribeStream: 'static,
776        {
777            let factory = crate::subscriptions::incoming::BidiSubscriptionFactory {
778                api: api_client.clone(),
779            };
780            let mut builder = self.api_client(api_client);
781            builder.incoming_factory = Some(Arc::new(factory));
782            builder
783        }
784    }
785
786    xmtp_common::if_wasm! {
787        /// Attach a browser API client with a lazy static-stream receiver.
788        /// The factory owns the API client, not the client context.
789        pub fn api_client_with_streams<A>(self, api_client: A) -> ClientBuilder<A, S, Db>
790        where
791            A: xmtp_proto::api_client::XmtpMlsStreams + Clone + 'static,
792        {
793            let api = api_client.clone();
794            let mut builder = self.api_client(api_client);
795            builder.incoming_factory = Some(Arc::new(move |cursors: xmtp_proto::types::TopicCursor, limits| -> crate::subscriptions::incoming::SubscriptionFuture {
796                let api = api.clone();
797                Box::pin(async move {
798                    api.subscribe_envelopes_with_cursors(&cursors, limits)
799                        .await
800                        .map(|subscription| subscription.map_error(xmtp_proto::api::NetworkError::new))
801                        .map_err(xmtp_proto::api::NetworkError::new)
802                })
803            }));
804            builder
805        }
806    }
807
808    pub fn maybe_version(
809        mut self,
810        version: Option<VersionInfo>,
811    ) -> ClientBuilder<ApiClient, S, Db> {
812        if let Some(v) = version {
813            self.version_info = v;
814        }
815        self
816    }
817
818    pub fn version(self, version_info: VersionInfo) -> ClientBuilder<ApiClient, S, Db> {
819        Self {
820            version_info,
821            ..self
822        }
823    }
824
825    /// Skip network calls when building a client
826    pub fn with_allow_offline(
827        self,
828        allow_offline: Option<bool>,
829    ) -> ClientBuilder<ApiClient, S, Db> {
830        Self {
831            allow_offline: allow_offline.unwrap_or(false),
832            ..self
833        }
834    }
835
836    /// Control whether the CommitLogWorker background task is enabled.
837    /// Useful for tests that need deterministic commit log operations.
838    #[cfg(any(test, feature = "test-utils"))]
839    pub fn with_commit_log_worker(self, enabled: bool) -> Self {
840        Self {
841            disable_commit_log_worker: !enabled,
842            ..self
843        }
844    }
845
846    #[cfg(any(test, feature = "test-utils"))]
847    pub fn enable_sqlite_triggers(self) -> Self
848    where
849        Db: XmtpDb,
850    {
851        let db = self.store.as_ref().expect("unwrapping in test env").conn();
852        let db = xmtp_db::DbConnection::new(db);
853        db.register_triggers();
854        db.disable_memory_security();
855        self
856    }
857
858    pub fn enable_api_stats(
859        self,
860    ) -> Result<ClientBuilder<TrackedStatsClient<ApiClient>, S, Db>, ClientBuilderError> {
861        if self.api_client.is_none() {
862            return Err(ClientBuilderError::MissingParameter {
863                parameter: "api_client",
864            });
865        }
866
867        Ok(ClientBuilder {
868            api_client: Some(TrackedStatsClient::new(
869                self.api_client.expect("checked for none"),
870            )),
871            identity: self.identity,
872            identity_strategy: self.identity_strategy,
873            scw_verifier: self.scw_verifier,
874            custom_scw_verifier: self.custom_scw_verifier,
875            store: self.store,
876
877            device_sync_worker_mode: self.device_sync_worker_mode,
878            fork_recovery_opts: self.fork_recovery_opts,
879            change_callbacks: self.change_callbacks,
880            stream_policy: self.stream_policy,
881            incoming_factory: self.incoming_factory,
882            version_info: self.version_info,
883            allow_offline: self.allow_offline,
884            disable_commit_log_worker: self.disable_commit_log_worker,
885            mls_storage: self.mls_storage,
886            disable_workers: self.disable_workers,
887            worker_config: self.worker_config,
888            config_provider: self.config_provider,
889        })
890    }
891
892    pub fn with_scw_verifier(
893        self,
894        verifier: impl SmartContractSignatureVerifier + 'static,
895    ) -> ClientBuilder<ApiClient, S, Db> {
896        ClientBuilder {
897            api_client: self.api_client,
898            identity: self.identity,
899            identity_strategy: self.identity_strategy,
900            scw_verifier: Some(Box::new(verifier)),
901            custom_scw_verifier: true,
902            store: self.store,
903
904            device_sync_worker_mode: self.device_sync_worker_mode,
905            fork_recovery_opts: self.fork_recovery_opts,
906            change_callbacks: self.change_callbacks,
907            stream_policy: self.stream_policy,
908            incoming_factory: self.incoming_factory,
909            version_info: self.version_info,
910            allow_offline: self.allow_offline,
911            disable_commit_log_worker: self.disable_commit_log_worker,
912            mls_storage: self.mls_storage,
913            disable_workers: self.disable_workers,
914            worker_config: self.worker_config,
915            config_provider: self.config_provider,
916        }
917    }
918
919    /// Build the client with a default remote verifier
920    /// requires the 'api' to be set.
921    pub fn with_remote_verifier(self) -> Result<ClientBuilder<ApiClient, S, Db>, ClientBuilderError>
922    where
923        ApiClient: Clone + XmtpApi + 'static,
924    {
925        let api = self
926            .api_client
927            .clone()
928            .ok_or(ClientBuilderError::MissingParameter {
929                parameter: "api_client",
930            })?;
931
932        Ok(ClientBuilder {
933            api_client: self.api_client,
934            identity: self.identity,
935            identity_strategy: self.identity_strategy,
936            scw_verifier: Some(Box::new(ApiClientWrapper::new(api, Retry::default()))
937                as Box<dyn SmartContractSignatureVerifier>),
938            // CFG-069 exempts an app-supplied verifier, and this replaces any
939            // the caller set with the default one, so the exemption ends here.
940            custom_scw_verifier: false,
941            store: self.store,
942            device_sync_worker_mode: self.device_sync_worker_mode,
943            fork_recovery_opts: self.fork_recovery_opts,
944            change_callbacks: self.change_callbacks,
945            stream_policy: self.stream_policy,
946            incoming_factory: self.incoming_factory,
947            version_info: self.version_info,
948            allow_offline: self.allow_offline,
949            disable_commit_log_worker: self.disable_commit_log_worker,
950            mls_storage: self.mls_storage,
951            disable_workers: self.disable_workers,
952            worker_config: self.worker_config,
953            config_provider: self.config_provider,
954        })
955    }
956}
957
958#[cfg(test)]
959mod worker_registration_tests {
960    use crate::tester;
961    use crate::worker::{WorkerConfig, WorkerKind};
962
963    #[xmtp_common::test(unwrap_try = true)]
964    #[cfg_attr(target_arch = "wasm32", ignore)]
965    async fn disabled_worker_is_not_registered() {
966        let mut cfg = WorkerConfig::default();
967        cfg.enabled.insert(WorkerKind::DisappearingMessages, false);
968        tester!(alix, worker_config: cfg);
969
970        let kinds = alix.client.workers.registered_kinds();
971        assert!(
972            !kinds.contains(&WorkerKind::DisappearingMessages),
973            "disabled worker must not be registered, got {kinds:?}"
974        );
975        assert!(
976            kinds.contains(&WorkerKind::TaskRunner),
977            "un-disabled worker must still be registered, got {kinds:?}"
978        );
979    }
980}