Skip to main content

xmtp_mls/
inbox_handles.rs

1//! Inbox handles on the client.
2//!
3//! A handle is a short public name that an inbox claims in its identity log
4//! (`xmtp_id::associations::handle`). Only the backend answers which inbox
5//! holds a handle, so every path from a handle to an inbox goes through one
6//! resolver. It looks the handle up, loads the answered inbox's identity
7//! updates, and keeps the answer only when a state the client derived holds
8//! the handle. The client reports a changed display handle with the
9//! `handle.changed` event (`identity_updates::store_association_state`), and
10//! group members carry the handles of the newest state the client holds.
11//!
12//! The client applies no handle policy. The backend alone refuses reserved,
13//! short, taken, or too frequent claims, and the client never applies the
14//! published minimum name length.
15
16use crate::{
17    Client,
18    client::ClientError,
19    context::XmtpSharedContext,
20    groups::{GroupError, MlsGroup, UpdateGroupMembershipResult, group_permissions::PolicySet},
21    identity_updates::{
22        IdentityUpdates, get_association_state_for_client, get_association_state_with_verifier,
23        load_identity_updates, load_identity_updates_for_client,
24    },
25};
26use futures::future::try_join_all;
27use std::collections::HashMap;
28use xmtp_api::{ApiClientWrapper, XmtpApi};
29use xmtp_common::{ErrorCode, RetryableError};
30use xmtp_db::{NotFound, prelude::*};
31use xmtp_id::{
32    InboxId,
33    associations::{
34        AssociationError, AssociationState, Identifier, MAX_HANDLE_CLAIMS,
35        handle::{Handle, InvalidHandle},
36    },
37    scw_verifier::SmartContractSignatureVerifier,
38};
39use xmtp_mls_common::{
40    group::{DMMetadataOptions, GroupMetadataOptions},
41    group_metadata::DmMembers,
42};
43use xmtp_proto::{types::ApiIdentifier, xmtp::identity::associations::IdentifierKind};
44
45#[cfg(test)]
46mod tests;
47
48/// How many random codes [`Client::available_handles`] checks for a name
49/// without a code. With the name itself, one lookup holds 101 handles.
50pub const HANDLE_SEARCH_CODES: usize = 100;
51
52/// The number of four-digit codes, `0000` to `9999`.
53const HANDLE_CODE_COUNT: usize = 10_000;
54
55/// A handle operation that the client refuses.
56#[derive(Debug, thiserror::Error, ErrorCode)]
57pub enum HandleError {
58    #[error(transparent)]
59    #[error_code(inherit)]
60    Invalid(#[from] InvalidHandle),
61    /// No inbox holds the handle. Either the backend names no inbox for it,
62    /// or the inbox it names does not hold the handle in any state the client
63    /// derived from that inbox's log. Not retryable.
64    #[error("no inbox holds the handle")]
65    NotFound,
66    /// The inbox has made the most handle claims its identity log can hold,
67    /// so a validator rejects another. Not retryable.
68    #[error("the inbox has made the most handle claims it can")]
69    LimitReached,
70}
71
72impl RetryableError for HandleError {
73    fn is_retryable(&self) -> bool {
74        false
75    }
76}
77
78/// Someone a caller wants to reach: an account identifier, which resolves as
79/// it always has, or a handle, which resolves only through the verified
80/// handle resolver.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub enum IdentityTarget {
83    Account(Identifier),
84    Handle(Handle),
85}
86
87/// Fail with [`HandleError::LimitReached`] when `state` has made as many
88/// claims as one identity log can hold. A validator rejects one more, so the
89/// client does not sign it.
90pub(crate) fn ensure_claim_allowed(state: &AssociationState) -> Result<(), HandleError> {
91    if state.handle_claims() >= MAX_HANDLE_CLAIMS {
92        return Err(HandleError::LimitReached);
93    }
94    Ok(())
95}
96
97/// The lookup of one handle in `GetInboxIds`.
98fn handle_lookup(handle: &Handle) -> ApiIdentifier {
99    ApiIdentifier {
100        identifier: handle.as_str().to_owned(),
101        identifier_kind: IdentifierKind::Handle,
102    }
103}
104
105/// The inbox the backend names for each handle, in order. Nothing here checks
106/// an answer.
107async fn lookup_handles<A: XmtpApi>(
108    api: &ApiClientWrapper<A>,
109    handles: &[Handle],
110) -> Result<Vec<Option<InboxId>>, ClientError> {
111    if handles.is_empty() {
112        return Ok(Vec::new());
113    }
114    Ok(api
115        .get_inbox_ids(handles.iter().map(handle_lookup).collect())
116        .await?)
117}
118
119/// Each inbox that an answer names, once.
120fn answered_inboxes(answers: &[Option<InboxId>]) -> Vec<InboxId> {
121    let mut inboxes: Vec<InboxId> = answers.iter().flatten().cloned().collect();
122    inboxes.sort_unstable();
123    inboxes.dedup();
124    inboxes
125}
126
127/// An inbox's state, or `None` when its log holds no update. Such an inbox
128/// holds no handle.
129fn state_if_logged(
130    result: Result<AssociationState, ClientError>,
131) -> Result<Option<AssociationState>, ClientError> {
132    match result {
133        Ok(state) => Ok(Some(state)),
134        Err(ClientError::Association(AssociationError::MissingIdentityUpdate)) => Ok(None),
135        Err(error) => Err(error),
136    }
137}
138
139/// Keep each answer only when the newest state of the inbox it names holds
140/// the handle. Nothing removes a handle, so the newest state holds every
141/// handle the inbox ever claimed.
142// implements: IDENT-085
143fn keep_verified(
144    handles: &[Handle],
145    answers: Vec<Option<InboxId>>,
146    states: &HashMap<InboxId, AssociationState>,
147) -> Vec<Option<InboxId>> {
148    handles
149        .iter()
150        .zip(answers)
151        .map(|(handle, answer)| {
152            answer.filter(|inbox_id| {
153                states
154                    .get(inbox_id)
155                    .is_some_and(|state| state.handles().contains(handle))
156            })
157        })
158        .collect()
159}
160
161/// The verified handle resolver: look the handles up, load the identity
162/// updates of every answered inbox in one incremental batch, derive each
163/// inbox's newest state, and drop any answer whose state lacks its handle.
164pub(crate) async fn resolve_handles_in<Context: XmtpSharedContext>(
165    context: &Context,
166    handles: &[Handle],
167) -> Result<Vec<Option<InboxId>>, ClientError> {
168    let answers = lookup_handles(context.api(), handles).await?;
169    let inboxes = answered_inboxes(&answers);
170    if inboxes.is_empty() {
171        return Ok(answers);
172    }
173    let conn = context.db();
174    let inbox_refs: Vec<&str> = inboxes.iter().map(String::as_str).collect();
175    load_identity_updates_for_client(context, &conn, &inbox_refs).await?;
176    let states = try_join_all(inboxes.into_iter().map(|inbox_id| async {
177        let state = get_association_state_for_client(context, &conn, &inbox_id, None).await;
178        Ok::<_, ClientError>(state_if_logged(state)?.map(|state| (inbox_id, state)))
179    }))
180    .await?;
181    let states: HashMap<InboxId, AssociationState> = states.into_iter().flatten().collect();
182    Ok(keep_verified(handles, answers, &states))
183}
184
185/// The verified handle resolver for a caller with no client, such as an SDK
186/// static helper. It stores the states it derives, with no event.
187pub async fn resolve_handles_with_verifier<A: XmtpApi>(
188    api: &ApiClientWrapper<A>,
189    conn: &impl DbQuery,
190    handles: &[Handle],
191    verifier: &impl SmartContractSignatureVerifier,
192) -> Result<Vec<Option<InboxId>>, ClientError> {
193    let answers = lookup_handles(api, handles).await?;
194    let inboxes = answered_inboxes(&answers);
195    if inboxes.is_empty() {
196        return Ok(answers);
197    }
198    let inbox_refs: Vec<&str> = inboxes.iter().map(String::as_str).collect();
199    load_identity_updates(api, conn, &inbox_refs).await?;
200    let mut states = HashMap::new();
201    for inbox_id in inboxes {
202        let state = get_association_state_with_verifier(conn, &inbox_id, None, verifier).await;
203        if let Some(state) = state_if_logged(state)? {
204            states.insert(inbox_id, state);
205        }
206    }
207    Ok(keep_verified(handles, answers, &states))
208}
209
210/// The inbox of each target, in order. Handles resolve through the verified
211/// resolver first, and a handle that no inbox holds fails with
212/// [`HandleError::NotFound`]. Accounts then resolve with one lookup, and an
213/// account with no inbox is `None`, so each route treats it as its
214/// account-only path does.
215pub(crate) async fn resolve_targets_in<Context: XmtpSharedContext>(
216    context: &Context,
217    targets: &[IdentityTarget],
218) -> Result<Vec<Option<InboxId>>, ClientError> {
219    let handles: Vec<Handle> = targets
220        .iter()
221        .filter_map(|target| match target {
222            IdentityTarget::Handle(handle) => Some(handle.clone()),
223            IdentityTarget::Account(_) => None,
224        })
225        .collect();
226    let mut handle_inboxes = resolve_handles_in(context, &handles).await?.into_iter();
227    let accounts: Vec<ApiIdentifier> = targets
228        .iter()
229        .filter_map(|target| match target {
230            IdentityTarget::Account(identifier) => Some(identifier.into()),
231            IdentityTarget::Handle(_) => None,
232        })
233        .collect();
234    let mut account_inboxes = if accounts.is_empty() {
235        Vec::new()
236    } else {
237        context.api().get_inbox_ids(accounts).await?
238    }
239    .into_iter();
240    targets
241        .iter()
242        .map(|target| match target {
243            IdentityTarget::Handle(_) => handle_inboxes
244                .next()
245                .flatten()
246                .map(Some)
247                .ok_or_else(|| HandleError::NotFound.into()),
248            IdentityTarget::Account(_) => Ok(account_inboxes.next().flatten()),
249        })
250        .collect()
251}
252
253/// The inboxes of `targets`, failing as an account-only add does
254/// (`add_members_by_identity`) when an account has no inbox.
255fn require_account_inboxes(
256    targets: &[IdentityTarget],
257    inboxes: Vec<Option<InboxId>>,
258) -> Result<Vec<InboxId>, GroupError> {
259    let missing: Vec<String> = targets
260        .iter()
261        .zip(&inboxes)
262        .filter_map(|(target, inbox)| match (target, inbox) {
263            (IdentityTarget::Account(identifier), None) => Some(identifier.to_string()),
264            _ => None,
265        })
266        .collect();
267    if !missing.is_empty() {
268        return Err(GroupError::AddressNotFound(missing));
269    }
270    Ok(inboxes.into_iter().flatten().collect())
271}
272
273/// The account identifiers of `targets`, when every target is an account.
274fn all_accounts(targets: &[IdentityTarget]) -> Option<Vec<Identifier>> {
275    targets
276        .iter()
277        .map(|target| match target {
278            IdentityTarget::Account(identifier) => Some(identifier.clone()),
279            IdentityTarget::Handle(_) => None,
280        })
281        .collect()
282}
283
284/// The handles to look up for `handle`: only itself when it has a code.
285/// Otherwise itself first, then its name with [`HANDLE_SEARCH_CODES`]
286/// distinct random codes.
287fn search_candidates(handle: Handle) -> Vec<Handle> {
288    if handle.name() != handle.as_str() {
289        return vec![handle];
290    }
291    let codes = rand::seq::index::sample(
292        &mut xmtp_common::rng(),
293        HANDLE_CODE_COUNT,
294        HANDLE_SEARCH_CODES,
295    );
296    let coded: Vec<Handle> = codes
297        .into_iter()
298        .map(|code| {
299            let code = u16::try_from(code).expect("a sampled code is below 10 000");
300            Handle::with_code(handle.name(), code)
301                .expect("a name part with a four-digit code is a handle")
302        })
303        .collect();
304    std::iter::once(handle).chain(coded).collect()
305}
306
307impl<Context: XmtpSharedContext> Client<Context> {
308    /// The inbox that holds each handle, in order, or `None`. An answer from
309    /// the backend counts only when the newest state the client derives for
310    /// the answered inbox holds the handle. An old handle of an inbox still
311    /// resolves to it.
312    pub async fn resolve_handles(
313        &self,
314        handles: &[Handle],
315    ) -> Result<Vec<Option<InboxId>>, ClientError> {
316        resolve_handles_in(&self.context, handles).await
317    }
318
319    /// Whether the backend names an inbox for each handle, in order. This
320    /// checks no association state: use it only where a wrong answer costs a
321    /// candidate, never to reach an inbox.
322    pub async fn handles_have_owner(&self, handles: &[Handle]) -> Result<Vec<bool>, ClientError> {
323        Ok(lookup_handles(self.context.api(), handles)
324            .await?
325            .iter()
326            .map(Option::is_some)
327            .collect())
328    }
329
330    /// The handles for `input` that no inbox holds. `input` is a handle as a
331    /// user typed it; its ASCII capitals become lowercase. Input with a code
332    /// checks only that handle. A name without a code checks the name and
333    /// [`HANDLE_SEARCH_CODES`] distinct random codes in one lookup, and the
334    /// name comes first in the result. Text outside the handle grammar fails
335    /// before any request. No policy applies here, so a returned handle can
336    /// still be refused when it is claimed.
337    // implements: CONF-081
338    pub async fn available_handles(&self, input: &str) -> Result<Vec<Handle>, ClientError> {
339        let handle = Handle::from_input(input).map_err(HandleError::from)?;
340        let candidates = search_candidates(handle);
341        let owned = self.handles_have_owner(&candidates).await?;
342        Ok(candidates
343            .into_iter()
344            .zip(owned)
345            .filter_map(|(candidate, owned)| (!owned).then_some(candidate))
346            .collect())
347    }
348
349    /// Claim `handle` for this client's inbox, signed by this installation's
350    /// key. A claim of a handle the inbox already holds makes it the display
351    /// handle again. The backend refuses a taken, reserved, short, or too
352    /// frequent claim with `ApiError::HandleRejected`. After the publish, the
353    /// client has stored the inbox's new identity update.
354    pub async fn claim_handle(&self, handle: &Handle) -> Result<(), ClientError> {
355        self.ensure_identity_ready()?;
356        let identity_updates = IdentityUpdates::new(&self.context);
357        let conn = self.context.db();
358        load_identity_updates_for_client(&self.context, &conn, &[self.inbox_id()]).await?;
359        let state = identity_updates
360            .get_association_state(&conn, self.inbox_id(), None)
361            .await?;
362        ensure_claim_allowed(&state)?;
363        let request = identity_updates
364            .claim_handle_request(handle.clone())
365            .await?;
366        identity_updates.apply_signature_request(request).await
367    }
368
369    /// The inbox of each target, in order, from one call. Fails when any
370    /// target has no inbox: an account as `find_or_create_dm_by_identity`
371    /// does, and a handle with [`HandleError::NotFound`].
372    pub async fn resolve_targets(
373        &self,
374        targets: &[IdentityTarget],
375    ) -> Result<Vec<InboxId>, ClientError> {
376        let inboxes = resolve_targets_in(&self.context, targets).await?;
377        targets
378            .iter()
379            .zip(inboxes)
380            .map(|(target, inbox)| match (target, inbox) {
381                (_, Some(inbox)) => Ok(inbox),
382                (IdentityTarget::Account(identifier), None) => {
383                    Err(NotFound::InboxIdForAddress(identifier.to_string()).into())
384                }
385                (IdentityTarget::Handle(_), None) => Err(HandleError::NotFound.into()),
386            })
387            .collect()
388    }
389
390    /// The active DM with `target`, or `None`. A handle that no inbox holds
391    /// has no DM.
392    pub async fn find_dm_by_target(
393        &self,
394        target: &IdentityTarget,
395    ) -> Result<Option<MlsGroup<Context>>, ClientError> {
396        let peer = match target {
397            IdentityTarget::Account(identifier) => {
398                self.find_inbox_id_from_identifier(&self.context.db(), identifier.clone())
399                    .await?
400            }
401            IdentityTarget::Handle(handle) => self
402                .resolve_handles(std::slice::from_ref(handle))
403                .await?
404                .pop()
405                .flatten(),
406        };
407        let Some(peer) = peer else {
408            return Ok(None);
409        };
410        let stored = self.context.db().find_active_dm_group(&DmMembers {
411            member_one_inbox_id: self.inbox_id(),
412            member_two_inbox_id: peer.as_str(),
413        })?;
414        stored.map(|stored| self.group(&stored.id)).transpose()
415    }
416
417    /// Find the DM with `target`, or create it. A handle resolves before the
418    /// existing DM is looked up.
419    pub async fn find_or_create_dm_by_target(
420        &self,
421        target: &IdentityTarget,
422        opts: DMMetadataOptions,
423    ) -> Result<MlsGroup<Context>, ClientError> {
424        match target {
425            IdentityTarget::Account(identifier) => {
426                self.find_or_create_dm_by_identity(identifier.clone(), Some(opts))
427                    .await
428            }
429            IdentityTarget::Handle(handle) => {
430                let peer = self
431                    .resolve_handles(std::slice::from_ref(handle))
432                    .await?
433                    .pop()
434                    .flatten()
435                    .ok_or(HandleError::NotFound)?;
436                self.find_or_create_dm(peer, Some(opts)).await
437            }
438        }
439    }
440
441    /// Create a group with `targets` as its members, in one commit. A list of
442    /// accounts only keeps the account path and its errors. Any other list
443    /// resolves every target before the group exists, and an account with no
444    /// inbox fails as on the account path.
445    pub async fn create_group_with_targets(
446        &self,
447        targets: &[IdentityTarget],
448        permissions_policy_set: Option<PolicySet>,
449        opts: Option<GroupMetadataOptions>,
450    ) -> Result<MlsGroup<Context>, ClientError> {
451        if let Some(accounts) = all_accounts(targets) {
452            return self
453                .create_group_with_identifiers(&accounts, permissions_policy_set, opts)
454                .await;
455        }
456        let inboxes = resolve_targets_in(&self.context, targets).await?;
457        let inbox_ids = require_account_inboxes(targets, inboxes)?;
458        self.create_group_with_members(&inbox_ids, permissions_policy_set, opts)
459            .await
460    }
461}
462
463impl<Context: XmtpSharedContext> MlsGroup<Context> {
464    /// Add `targets` to the group in one commit. A list of accounts only
465    /// keeps the account path and its errors. Any other list resolves every
466    /// target first and fails before any commit when a target has no inbox,
467    /// with the account path's error for an account.
468    pub async fn add_members_by_targets(
469        &self,
470        targets: &[IdentityTarget],
471    ) -> Result<UpdateGroupMembershipResult, GroupError> {
472        if let Some(accounts) = all_accounts(targets) {
473            return self.add_members_by_identity(&accounts).await;
474        }
475        let inboxes = resolve_targets_in(&self.context, targets).await?;
476        let inbox_ids = require_account_inboxes(targets, inboxes)?;
477        self.add_members(&inbox_ids).await
478    }
479
480    /// Remove `targets` from the group in one commit. A list of accounts only
481    /// keeps the account path and its errors. Any other list resolves every
482    /// target first. It fails before any commit when a handle has no inbox,
483    /// and skips an account with no inbox, as the account path does.
484    pub async fn remove_members_by_targets(
485        &self,
486        targets: &[IdentityTarget],
487    ) -> Result<(), GroupError> {
488        if let Some(accounts) = all_accounts(targets) {
489            return self.remove_members_by_identity(&accounts).await;
490        }
491        let inbox_ids: Vec<InboxId> = resolve_targets_in(&self.context, targets)
492            .await?
493            .into_iter()
494            .flatten()
495            .collect();
496        let inbox_ids: Vec<&str> = inbox_ids.iter().map(String::as_str).collect();
497        self.remove_members(&inbox_ids).await
498    }
499}