Overview
whatsapp-rust uses a trait-based storage system to persist device state, cryptographic keys, and protocol metadata. The storage layer is split into five domain-specific traits:- SignalStore - Signal protocol cryptographic operations (identity keys, sessions, pre-keys, sender keys)
- AppSyncStore - WhatsApp app state synchronization (sync keys, versions, mutation MACs)
- ProtocolStore - WhatsApp protocol alignment (SKDM tracking, LID-PN mapping, device registry)
- MsgSecretStore -
messageSecretpersistence for poll/edit/bot-reply decryption (added in v0.6) - DeviceStore - Device persistence operations
Backend trait for convenience.
The backend trait
Any type implementing all five domain traits automatically implementsBackend:
MsgSecretStore became a required member of Backend in v0.6, so a custom backend must implement it (the bundled SqliteStore already does). Its methods have defaults that keep the surface small — see MsgSecretStore for which methods you actually need to write.SignalStore Trait
Handles Signal protocol cryptographic storage for end-to-end encryption.Identity Operations
Session Operations
PreKey Operations
Signed PreKey operations
Sender key operations
For group messaging encryption:AppSyncStore Trait
Handles WhatsApp app state synchronization storage.Sync key operations
Version Tracking
Mutation MAC Operations
get_mutation_macs was added in v0.6 to collapse the app-state sync’s per-mutation previous-MAC lookups (which were N+1) into a single batched query. It has a default implementation, so custom backends that do not override it keep working without changes — override it with a WHERE index_mac IN (…) query for the performance win. The SQLite store chunks the IN list at 500 entries.An index MAC is always a full HMAC-SHA256 output, so this method’s signature uses inline [u8; 32] arrays (wacore::appstate_sync::IndexMac) instead of Vec<u8> — zero per-MAC heap allocations on either side of the batch lookup. This is a breaking change for custom backends that override get_mutation_macs: update the parameter and return-map key types to [u8; 32]. get_mutation_mac, put_mutation_macs, and delete_mutation_macs are unaffected.ProtocolStore Trait
Handles WhatsApp protocol alignment and tracking.Per-device sender key tracking
Tracks sender key distribution status per device in groups, matching WhatsApp Web’sparticipant.senderKey Map<deviceJid, boolean> model. Each device has a boolean indicating whether it holds a valid sender key (true) or needs a fresh SKDM (false).
LID-PN Mapping
Manages mappings between LID (Locally Indexed Device) and phone numbers:Base key collision detection
Device Registry
TcToken Storage
Trusted contact privacy tokens:delete_expired_tc_tokens gained a second sender_cutoff parameter — this is a breaking change for any custom backend that overrides it; update the signature to (&self, token_cutoff: i64, sender_cutoff: i64) -> Result<u32> and prune on both windows independently (see the built-in SqliteStore/InMemoryBackend implementations for the two-filter pattern).touch_tc_token_sender_timestamp and store_received_tc_token are defaulted methods — existing custom backends compile and work unchanged, but should override both with an atomic upsert if the backend supports one, since the default read-modify-write can race a concurrent writer touching the same row (post-send issuance vs. an incoming privacy_token notification). For store_received_tc_token specifically, a non-atomic override that races two callers can let an older token’s write land last and clobber a fresher one — the built-in backends close this by making the newer-wins check part of the same read+write.Sent message store
Persists sent message payloads for retry handling. Matches WhatsApp Web’sgetMessageTable pattern where retry receipts look up the original message from storage.
The
take_sent_message method is an atomic read-and-delete operation. Once a message payload is taken for retry, it is removed from storage to prevent double-retry. For status broadcasts where multiple devices may retry, the client re-adds the message after taking it.MsgSecretStore
The fifth required member ofBackend. It persists the 32-byte messageSecret values needed to decrypt later add-ons keyed off an original message: poll votes, poll/event edits, message edits (secret_encrypted_message), and Meta AI / fbid bot replies (<enc type="msmsg">). Secrets are keyed by (chat, sender, msg_id) and carry an absolute expiry so they can be pruned by policy (see messageSecret retention).
MsgSecretEntry is { chat, sender, msg_id, secret, expires_at, message_ts }. The SQLite table is:
A custom backend only needs to implement three methods:
put_msg_secrets, get_msg_secret, and delete_expired_msg_secrets. The other two have defaults — put_msg_secret delegates to put_msg_secrets with expires_at = 0, and get_msg_secret_with_ts pairs get_msg_secret with a 0 timestamp. Override get_msg_secret_with_ts only if your store persists message_ts and you want the edit-window enforced.DeviceStore Trait
Handles device data persistence:resource_report
SqliteStore’s SQLite page cache, often the single largest per-session chunk since it lives entirely outside the Client) can report it for Client::resource_report(). Remote/store-backed backends (Redis, etc.) should override it to return memory_bytes: Some(0) — their data isn’t process memory — rather than leaving the default all-None (“not reported”), if they want to make that positive claim explicit.
SqliteStore implementation
The default storage implementation using SQLite with Diesel ORM. SQLite is bundled by default — you don’t need it installed on your system.Bundled SQLite
Thewhatsapp-rust-sqlite-storage crate enables the bundled-sqlite feature by default, which compiles SQLite from source and statically links it. To use a system-installed SQLite instead:
Cargo.toml
Creating a store
Sharing the pool with sibling crates
SqliteStore::shared() returns a clonable SharedSqlite handle onto the store’s existing r2d2 connection pool and write-serialization semaphore. A sibling crate uses it to run its own queries and migrations against the same database file — without opening a second connection pool, which would mean two WAL writers contending for the file lock. This is how whatsapp-rust-chat-store attaches its chat/message tables to the same whatsapp.db file as the device store.
Features
- Connection pooling - Uses Diesel r2d2 with pool size of 2
- WAL mode - Write-Ahead Logging for better concurrency
- Automatic migrations - Runs embedded migrations on startup
- Semaphore-based locking - Prevents concurrent writes
- Retry logic - Automatic retry with exponential backoff for locked database
- Multi-device support - Single database can store multiple device sessions
Database Configuration
SqliteStore automatically configures connections with:SqliteStoreConfig also exposes an opt-in mmap_size: Option<u64> field (default None, current behavior — no PRAGMA mmap_size emitted). Set it with the builder-style with_mmap_size(bytes):
with_mmap_size only sets the field on the config value — the store applies it when the config is passed to SqliteStore::with_config (or with_config_for_device). Building a config and never passing it to one of those constructors leaves mmap_size unset, since SqliteStore::new / new_for_device always use SqliteStoreConfig::default().
When set to a non-zero value, this emits PRAGMA mmap_size = <bytes>;, moving reads of the main database file through a reclaimable, OS-backed memory map instead of the heap page cache — useful for a process holding many small per-session databases, since mapped pages can be reclaimed under memory pressure while heap-cached pages cannot. 0 disables mmap, same as None.
Usage Example
CacheStore Trait
TheCacheStore trait enables pluggable cache backends for the client’s data caches. By default, caches use the in-process PortableCache; implementing this trait lets you use Redis, Memcached, or any other external cache.
Location: wacore/src/store/cache.rs
Namespaces
Each logical cache uses a unique namespace string. Implementations should partition keys by namespace (e.g., prefix as{namespace}:{key} in Redis).
Error handling
Cache operations are best-effort. The client treats read failures as cache misses and logs warnings on write failures. Implementations should still return errors for observability.CacheStores configuration
CacheStores::all(store) to route all pluggable caches to the same backend:
TypedCache
TypedCache<K, V> is a generic wrapper that dispatches to either the in-process PortableCache or a custom CacheStore backend.
Location: src/cache_store.rs
Implementing custom storage
To implement a custom storage backend:- Implement all four domain traits
- The
Backendtrait is automatically implemented - All methods must be
asyncand thread-safe (Send + Sync)
Example: Redis store
Best Practices
- Thread Safety - Use
Arcfor shared state,Mutexfor mutable state - Error Handling - Convert backend errors to
StoreErrorvariants - Transactions - Use database transactions for atomic operations
- Retries - Implement retry logic for transient failures
- Connection Pooling - Reuse connections when possible
- Blocking Operations - Wrap blocking I/O in
tokio::task::spawn_blocking
Data Structures
AppStateSyncKey
LidPnMappingEntry
TcTokenEntry
An entry with an empty
token and only sender_timestamp set is a byte-less placeholder written by touch_tc_token_sender_timestamp — it records that a post-send issuance IQ succeeded before any real token had been received from the contact.DeviceListRecord
raw_id field stores the ADV (Account Device Verification) key index list raw_id from device notifications. When this value changes for a user, it indicates an identity change (e.g., the user reinstalled WhatsApp). The client uses this to detect identity changes and clear Signal sessions for that user’s non-primary devices. Per-device sender key tracking is not wiped globally on identity change — that would empty the tracker too aggressively and feed the no-distribution path on the next group send. SKDM redistribution is instead driven per-group/per-device by retry receipts (matching WhatsApp Web’s WAWebUpdateLocalSignalSession/markForgetSenderKey behavior).
Error Handling
All storage operations returnResult<T> from wacore::store::error. Each variant preserves the underlying typed error as its source() so callers can downcast to the original backend error when needed:
StoreError exposes a helper is_database_busy_or_locked() that walks the source chain looking for SQLite BUSY/LOCKED markers. Retry layers use it to decide whether a database error is transient without depending on a specific backend crate.See also
- Transport Trait - Network transport abstraction
- HTTP Client Trait - HTTP client abstraction
- Client API - Main client interface
- Chat Store - Optional chat/message history built on
SqliteStore::shared()