Understanding the client lifecycle is essential for building XMTP applications. This guide covers client initialization, identity registration, and ongoing management.
Overview
An XMTP client progresses through these stages:
- Builder Configuration: Set up API, storage, and identity
- Client Construction: Initialize context and workers
- Identity Registration: Link installation to inbox (if needed)
- Ready State: Client can create/join groups and send messages
- Lifecycle Management: Reconnection, cleanup, and shutdown
Client Architecture
The Client<Context> is generic over its context:
pub struct Client<Context> {
pub context: Context,
pub installation_id: InstallationId,
pub(crate) local_events: broadcast::Sender<LocalEvents>,
pub(crate) workers: Arc<WorkerRunner>,
}
Typically, Context is Arc<XmtpMlsLocalContext<ApiClient, Db, S>> containing:
- Identity and installation keys
- API client for network communication
- Database for state and messages
- MLS storage for cryptographic material
- Smart contract verifier
- Locks and mutexes for concurrency
- Event channels
Building a Client
Identity Strategy
Choose how to handle identity:
pub enum IdentityStrategy {
/// Create identity if database is new, load if exists
CreateIfNotFound {
inbox_id: String,
identifier: Identifier,
nonce: u64,
credential: XmtpInstallationCredential,
},
/// Only load from existing database
CachedOnly,
/// Use externally provided identity
External(Identity),
}
CreateIfNotFound (most common):
- Generates installation keys on first run
- Derives inbox ID from wallet + nonce
- Stores identity in database
- Subsequent runs load from database
CachedOnly:
- For clients that must already exist
- Fails if database doesn’t contain identity
- Useful for re-initializing after disconnect
External:
- Advanced use case
- Manually construct
Identity and pass in
- Full control over key generation
Basic Builder Pattern
let client = Client::builder(IdentityStrategy::CreateIfNotFound {
inbox_id: inbox_id.clone(),
identifier: wallet_identifier,
nonce: 0,
credential: XmtpInstallationCredential::new(),
})
.api_clients(api_client, sync_api_client)
.store(encrypted_store)
.default_mls_store()?
.with_scw_verifier(verifier)
.build()
.await?;
Configuration Options
API Clients
.api_clients(api_client, sync_api_client)
Two API clients are required:
- api_client: Main network operations (send messages, add members)
- sync_api_client: Background sync operations (pull messages, welcomes)
Typically these are the same client, but can be different for advanced scenarios.
Storage
.store(encrypted_store)
.default_mls_store()?
Application Store:
- Encrypted SQLite database via
xmtp_db
- Stores messages, groups, identity updates, consent
MLS Store:
- Stores OpenMLS state (epochs, key packages, ratchet trees)
- Default:
SqlKeyStore backed by same database
- Can provide custom implementation
Use EncryptedMessageStore::new() or EncryptedMessageStore::new_unencrypted() to create the application store. Encryption key should be securely stored by the application.
Smart Contract Verifier
.with_scw_verifier(verifier)
Or use the remote verifier:
Required for validating smart contract wallet signatures. Options:
- RemoteSignatureVerifier: Uses XMTP validation service
- ChainRpcVerifier: Validates directly via EVM RPC (requires RPC URLs)
- Custom implementation
See Identity System for details on signature verification.
Workers
.with_disable_workers(true) // Disable background workers
.device_sync_worker_mode(DeviceSyncMode::Enabled) // Enable device sync
Workers run in background:
- SyncWorker: Polls for new messages (if device sync enabled)
- KeyPackagesCleanerWorker: Rotates key packages
- DisappearingMessagesWorker: Deletes expired messages
- PendingSelfRemoveWorker: Processes pending member removals
- CommitLogWorker: Publishes commit logs (optional)
Disabling workers is useful for testing or manual control.
Optional Configuration
.version(VersionInfo { ... }) // Set version metadata
.with_allow_offline(true) // Skip network calls during build
.fork_recovery_opts(opts) // Configure fork recovery behavior
Full Example
use xmtp_mls::client::{Client, ClientBuilder};
use xmtp_mls::builder::{IdentityStrategy, DeviceSyncMode};
use xmtp_id::associations::Identifier;
use xmtp_cryptography::XmtpInstallationCredential;
let wallet_identifier = Identifier::eth(&wallet_address)?;
let inbox_id = wallet_identifier.inbox_id(0)?;
let client = ClientBuilder::new(IdentityStrategy::CreateIfNotFound {
inbox_id: inbox_id.clone(),
identifier: wallet_identifier.clone(),
nonce: 0,
credential: XmtpInstallationCredential::new(),
})
.api_clients(api_client.clone(), api_client.clone())
.store(encrypted_store)
.default_mls_store()?
.with_remote_verifier()?
.device_sync_worker_mode(DeviceSyncMode::Enabled)
.build()
.await?;
Identity Registration
After building, the client must register its installation with the network.
Registration States
The client can be in three states:
1. New Inbox (No Previous Registration)
Inbox ID doesn’t exist on network:
// Build signature request
let mut signature_request = SignatureRequestBuilder::new(inbox_id)
.create_inbox(wallet_identifier.clone(), 0)
.add_association(
installation_identifier,
wallet_identifier.into(),
)
.build();
// Add installation signature (client can do this)
let installation_signature = credential.sign(&signature_request.signature_text())?;
signature_request.add_signature(
UnverifiedSignature::InstallationKey(installation_signature),
&scw_verifier,
).await?;
// Application must add wallet signature
let wallet_signature = wallet.sign(&signature_request.signature_text())?;
signature_request.add_signature(wallet_signature, &scw_verifier).await?;
// Register with network
client.register_identity(signature_request).await?;
What this does:
- Creates inbox on network
- Links wallet and installation to inbox
- Uploads key package
- Publishes identity update
After this, client.identity().is_ready() returns true.
2. Existing Inbox, New Installation
Inbox exists but this installation isn’t registered:
let mut signature_request = SignatureRequestBuilder::new(inbox_id)
.add_association(
installation_identifier,
existing_wallet_identifier.into(),
)
.build();
// Add signatures (installation + existing wallet)
signature_request.add_signature(installation_sig, &scw_verifier).await?;
signature_request.add_signature(wallet_sig, &scw_verifier).await?;
client.register_identity(signature_request).await?;
Any wallet already linked to the inbox can sign the AddAssociation action. It doesn’t have to be the original wallet.
3. Already Registered
Client installation is already registered:
if client.identity().is_ready() {
println!("Already registered!");
} else {
// Perform registration
}
register_identity() is idempotent and returns immediately if already registered.
Registration Flow Details
Inside register_identity() (from client.rs:875-942):
-
Check if already registered: Query database for
StoredIdentity
- If exists, mark ready and return
-
Generate key package: Create MLS key package with installation key
- Store locally (not uploaded yet)
-
Validate signatures: Verify all signatures before network calls
- Prevents polluting network with invalid updates
-
Upload key package: Send to network first
- Prevents race where identity update visible but no key package
-
Publish identity update: Make installation visible on network
-
Fetch and store: Download identity updates and store in local DB
- Needed for group operations
-
Clean up old key packages: Mark previous packages for deletion
-
Mark identity ready: Set
is_ready() flag
Do not attempt to create groups or send messages before register_identity() completes. Operations will fail with IdentityError::UninitializedIdentity.
Key Package Management
Rotation
Key packages should be rotated after use:
client.rotate_and_upload_key_package().await?;
When to rotate:
- After receiving a welcome message (key package consumed)
- Periodically (recommended but not enforced)
Automatic rotation:
The KeyPackagesCleanerWorker handles rotation based on schedule stored in database.
Manual Queueing
client.queue_key_rotation().await?;
Schedules rotation to occur within 5 seconds if none is scheduled.
Fetching Key Packages
Get current key packages for installations:
let packages = client.get_key_packages_for_installation_ids(
vec![installation_id_1, installation_id_2],
).await?;
for (id, result) in packages {
match result {
Ok(package) => println!("Valid package for {}", hex::encode(id)),
Err(e) => eprintln!("Invalid package: {}", e),
}
}
Used internally when adding members to groups.
Client Operations
Accessing Client Data
// Installation ID
let installation_id = client.installation_public_key();
// Inbox ID
let inbox_id = client.inbox_id();
// Database connection
let db = client.db();
// Identity
let identity = client.identity();
// Version info
let version = client.version_info();
Checking Registration Status
if !client.identity().is_ready() {
eprintln!("Client not ready. Call register_identity() first.");
return;
}
All group/message operations check this internally and return IdentityError::UninitializedIdentity if not ready.
Getting Inbox State
// Get current association state
let state = client.inbox_state(true).await?;
println!("Inbox ID: {}", state.inbox_id());
println!("Recovery address: {:?}", state.recovery_identifier());
println!("Installations: {:?}", state.installation_ids());
println!("Addresses: {:?}", state.identifiers());
Sequence ID
Get current sequence ID for your inbox:
let conn = client.db().conn();
let sequence_id = client.inbox_sequence_id(&conn)?;
Sequence ID increments with each identity update and is used for group membership tracking.
Lifecycle Management
Disconnecting Database
Release database connection (useful for mobile):
client.release_db_connection()?;
Workers are stopped and database is closed.
Reconnecting
Reconnect after releasing:
Reopens database and restarts workers.
Waiting for Workers
Ensure sync worker is initialized:
client.wait_for_sync_worker_init().await;
Blocks until SyncWorker is running and ready.
Sync Operations
Sync Welcomes
Discover new groups:
let new_groups = client.sync_welcomes().await?;
println!("Joined {} new groups", new_groups.len());
Sync All Groups
Update all existing groups:
let summary = client.sync_all_welcomes_and_groups(
Some(vec![ConsentState::Allowed]),
).await?;
println!("Synced {} groups", summary.total_groups_synced);
Optionally filter by consent state.
Error Handling
Common Errors
UninitializedIdentity:
Err(ClientError::Identity(IdentityError::UninitializedIdentity))
Solution: Call register_identity() before performing operations.
Storage Errors:
Err(ClientError::Storage(StorageError::...))
Solution: Check database connection, disk space, encryption key.
API Errors:
Err(ClientError::Api(ApiError::...))
Solution: Check network connectivity, retry with backoff.
Retryable Errors
Check if error should be retried:
use xmtp_common::RetryableError;
if error.is_retryable() {
// Retry operation
} else {
// Permanent failure
}
Testing
Test Utilities
Create test clients:
use xmtp_cryptography::utils::generate_local_wallet;
let wallet = generate_local_wallet();
let client = ClientBuilder::new_test_client(&wallet).await;
Mock Implementations
let api = MockApi::new();
let scw_verifier = MockSmartContractSignatureVerifier::new(false);
let client = ClientBuilder::new(strategy)
.api_clients(api.clone(), api)
.with_scw_verifier(scw_verifier)
.build()
.await?;
Offline Testing
let client = ClientBuilder::new(strategy)
.with_allow_offline(true)
.build()
.await?;
Skips network calls during initialization.
Best Practices
Secure Key Storage
The database encryption key must be securely stored by the application. LibXMTP does not manage encryption key storage.
Recommendations:
- Mobile: Use platform keychain (iOS Keychain, Android Keystore)
- Web: Consider Web Crypto API or secure server-side storage
- Desktop: OS keychain or encrypted file with user password
Worker Management
For production:
.device_sync_worker_mode(DeviceSyncMode::Enabled)
For testing:
.with_disable_workers(true)
Call sync methods manually when workers disabled.
Error Recovery
Implement retry logic with exponential backoff for transient errors:
use xmtp_common::{retry_async, Retry};
retry_async!(
Retry::default(),
(async { client.sync_welcomes().await })
)?;
Database Migration
Diesel handles migrations automatically. On first run:
let store = EncryptedMessageStore::new(
db_path,
encryption_key,
)?;
// Migrations run during initialization
Mobile (iOS/Android)
- Release DB connection when app backgrounds
- Reconnect when app foregrounds
- Use platform keychain for encryption key
- Consider battery impact of sync workers
Web (WASM)
- Use IndexedDB for storage
- Workers run in Web Workers (if supported)
- Consider IndexedDB size limits
- Handle offline scenarios
Node.js
- Use file-based SQLite
- Workers run in Node async runtime
- Consider clustering for multiple clients