Skip to main content

xmtp_mls_identity/
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 or an account, so every path from a handle or an account
6//! to an inbox goes through one resolver. It looks the target up, loads the
7//! answered inbox's identity updates, and keeps the answer only when the
8//! newest state the client derived holds the target. The client reports a
9//! changed display handle with the
10//! `handle.changed` event (`identity_updates::store_association_state`), and
11//! group members carry the handles of the newest state the client holds.
12//!
13//! The client applies no handle policy. The backend alone refuses reserved,
14//! short, taken, or too frequent claims, and the client never applies the
15//! published minimum name length.
16
17use crate::{
18    identity_updates::IdentityContext,
19    identity_updates::{
20        IdentityUpdateError, IdentityUpdates, get_association_state_for_client,
21        get_association_state_with_verifier, load_identity_updates,
22        load_identity_updates_for_client, state_holds_identifier,
23    },
24};
25use futures::{StreamExt, TryStreamExt, stream};
26use std::collections::HashMap;
27use xmtp_api::{ApiClientWrapper, XmtpApi};
28use xmtp_common::{ErrorCode, RetryableError};
29use xmtp_configuration::IDENTITY_DEPENDENCY_CONCURRENCY;
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_proto::{types::ApiIdentifier, xmtp::identity::associations::IdentifierKind};
40
41#[cfg(test)]
42mod tests;
43
44/// How many random codes `xmtp_mls::Client::available_handles` checks for a name
45/// without a code. With the name itself, one lookup holds 101 handles.
46pub const HANDLE_SEARCH_CODES: usize = 100;
47
48/// The number of four-digit codes, `0000` to `9999`.
49const HANDLE_CODE_COUNT: usize = 10_000;
50
51/// A handle operation that the client refuses.
52#[derive(Debug, thiserror::Error, ErrorCode)]
53pub enum HandleError {
54    #[error(transparent)]
55    #[error_code(inherit)]
56    Invalid(#[from] InvalidHandle),
57    /// No inbox holds the handle. Either the backend names no inbox for it,
58    /// or the inbox it names does not hold the handle in any state the client
59    /// derived from that inbox's log. Not retryable.
60    #[error("no inbox holds the handle")]
61    NotFound,
62    /// The inbox has made the most handle claims its identity log can hold,
63    /// so a validator rejects another. Not retryable.
64    #[error("the inbox has made the most handle claims it can")]
65    LimitReached,
66}
67
68impl RetryableError for HandleError {
69    fn is_retryable(&self) -> bool {
70        false
71    }
72}
73
74/// Someone a caller wants to reach: an account identifier or a handle.
75/// Either resolves to an inbox only through the verified resolver.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum IdentityTarget {
78    Account(Identifier),
79    Handle(Handle),
80}
81
82impl IdentityTarget {
83    /// The lookup of this target in `GetInboxIds`.
84    fn lookup(&self) -> ApiIdentifier {
85        match self {
86            Self::Account(identifier) => identifier.into(),
87            Self::Handle(handle) => ApiIdentifier {
88                identifier: handle.as_str().to_owned(),
89                identifier_kind: IdentifierKind::Handle,
90            },
91        }
92    }
93}
94
95/// `handles` as targets, in order.
96pub fn handle_targets(handles: &[Handle]) -> Vec<IdentityTarget> {
97    handles
98        .iter()
99        .cloned()
100        .map(IdentityTarget::Handle)
101        .collect()
102}
103
104/// `accounts` as targets, in order.
105pub fn account_targets(accounts: &[Identifier]) -> Vec<IdentityTarget> {
106    accounts
107        .iter()
108        .cloned()
109        .map(IdentityTarget::Account)
110        .collect()
111}
112
113/// Fail with [`HandleError::LimitReached`] when `state` has made as many
114/// claims as one identity log can hold. A validator rejects one more, so the
115/// client does not sign it.
116pub fn ensure_claim_allowed(state: &AssociationState) -> Result<(), HandleError> {
117    if state.handle_claims() >= MAX_HANDLE_CLAIMS {
118        return Err(HandleError::LimitReached);
119    }
120    Ok(())
121}
122
123/// The inbox the backend names for each target, in order, from one request.
124/// Nothing here checks an answer.
125async fn lookup_targets<A: XmtpApi>(
126    api: &ApiClientWrapper<A>,
127    targets: &[IdentityTarget],
128) -> Result<Vec<Option<InboxId>>, IdentityUpdateError> {
129    if targets.is_empty() {
130        return Ok(Vec::new());
131    }
132    Ok(api
133        .get_inbox_ids(targets.iter().map(IdentityTarget::lookup).collect())
134        .await?)
135}
136
137/// Each inbox that an answer names, once.
138fn answered_inboxes(answers: &[Option<InboxId>]) -> Vec<InboxId> {
139    let mut inboxes: Vec<InboxId> = answers.iter().flatten().cloned().collect();
140    inboxes.sort_unstable();
141    inboxes.dedup();
142    inboxes
143}
144
145/// An inbox's state, or `None` when its log holds no update. Such an inbox
146/// holds no account and no handle.
147fn state_if_logged(
148    result: Result<AssociationState, IdentityUpdateError>,
149) -> Result<Option<AssociationState>, IdentityUpdateError> {
150    match result {
151        Ok(state) => Ok(Some(state)),
152        Err(IdentityUpdateError::Association(AssociationError::MissingIdentityUpdate)) => Ok(None),
153        Err(error) => Err(error),
154    }
155}
156
157/// Whether `state`, the newest state of an inbox after this answer's load,
158/// holds `target`. Nothing removes a handle, so the newest state holds every
159/// handle the inbox ever claimed. A revocation removes an account, so an
160/// older state that holds it does not count. An Ethereum address matches in
161/// any letter case, and a passkey by its public key bytes.
162fn state_holds(state: &AssociationState, target: &IdentityTarget) -> bool {
163    match target {
164        IdentityTarget::Handle(handle) => state.handles().contains(handle),
165        IdentityTarget::Account(identifier) => state_holds_identifier(state, identifier),
166    }
167}
168
169/// Keep each answer only when the newest state of the inbox it names holds
170/// its target.
171// implements: IDENT-085, IDENT-096
172fn keep_verified(
173    targets: &[IdentityTarget],
174    answers: Vec<Option<InboxId>>,
175    states: &HashMap<InboxId, AssociationState>,
176) -> Vec<Option<InboxId>> {
177    targets
178        .iter()
179        .zip(answers)
180        .map(|(target, answer)| {
181            answer.filter(|inbox_id| {
182                states
183                    .get(inbox_id)
184                    .is_some_and(|state| state_holds(state, target))
185            })
186        })
187        .collect()
188}
189
190/// The verified resolver, the only reader of a `GetInboxIds` answer that
191/// picks an inbox: look the targets up in one request, load the identity
192/// updates of every answered inbox in one incremental batch, derive each
193/// inbox's newest state, at most [`IDENTITY_DEPENDENCY_CONCURRENCY`] at once,
194/// and drop any answer whose state does not hold its target.
195pub async fn resolve_verified_in<Context: IdentityContext>(
196    context: &Context,
197    targets: &[IdentityTarget],
198) -> Result<Vec<Option<InboxId>>, IdentityUpdateError> {
199    let answers = lookup_targets(context.api(), targets).await?;
200    let inboxes = answered_inboxes(&answers);
201    if inboxes.is_empty() {
202        return Ok(answers);
203    }
204    let conn = context.db();
205    let inbox_refs: Vec<&str> = inboxes.iter().map(String::as_str).collect();
206    load_identity_updates_for_client(context, &conn, &inbox_refs).await?;
207    // Collected first, as `IdentityUpdates::batch_get_association_state`
208    // does, so the future stays `Send` for every caller.
209    let reads: Vec<_> = inboxes
210        .into_iter()
211        .map(|inbox_id| async {
212            let state = get_association_state_for_client(context, &conn, &inbox_id, None).await;
213            Ok::<_, IdentityUpdateError>(state_if_logged(state)?.map(|state| (inbox_id, state)))
214        })
215        .collect();
216    let states: Vec<_> = stream::iter(reads)
217        .buffered(IDENTITY_DEPENDENCY_CONCURRENCY)
218        .try_collect()
219        .await?;
220    let states: HashMap<InboxId, AssociationState> = states.into_iter().flatten().collect();
221    Ok(keep_verified(targets, answers, &states))
222}
223
224/// The verified resolver for a caller with no client, such as an SDK static
225/// helper. It stores the states it derives, with no event.
226pub async fn resolve_verified_with_verifier<A: XmtpApi>(
227    api: &ApiClientWrapper<A>,
228    conn: &impl DbQuery,
229    targets: &[IdentityTarget],
230    verifier: &impl SmartContractSignatureVerifier,
231) -> Result<Vec<Option<InboxId>>, IdentityUpdateError> {
232    let answers = lookup_targets(api, targets).await?;
233    let inboxes = answered_inboxes(&answers);
234    if inboxes.is_empty() {
235        return Ok(answers);
236    }
237    let inbox_refs: Vec<&str> = inboxes.iter().map(String::as_str).collect();
238    load_identity_updates(api, conn, &inbox_refs).await?;
239    let mut states = HashMap::new();
240    for inbox_id in inboxes {
241        let state = get_association_state_with_verifier(conn, &inbox_id, None, verifier).await;
242        if let Some(state) = state_if_logged(state)? {
243            states.insert(inbox_id, state);
244        }
245    }
246    Ok(keep_verified(targets, answers, &states))
247}
248
249/// The inbox of each target, in order, from one verified lookup. A handle
250/// that no inbox holds fails with [`HandleError::NotFound`]. An account with
251/// no inbox is `None`, so each route treats it as its account-only path does.
252pub async fn resolve_targets_in<Context: IdentityContext>(
253    context: &Context,
254    targets: &[IdentityTarget],
255) -> Result<Vec<Option<InboxId>>, IdentityUpdateError> {
256    let inboxes = resolve_verified_in(context, targets).await?;
257    targets
258        .iter()
259        .zip(inboxes)
260        .map(|(target, inbox)| match (target, inbox) {
261            (IdentityTarget::Handle(_), None) => Err(HandleError::NotFound.into()),
262            (_, inbox) => Ok(inbox),
263        })
264        .collect()
265}
266
267/// The handles to look up for `handle`: only itself when it has a code.
268/// Otherwise itself first, then its name with [`HANDLE_SEARCH_CODES`]
269/// distinct random codes.
270fn search_candidates(handle: Handle) -> Vec<Handle> {
271    if handle.name() != handle.as_str() {
272        return vec![handle];
273    }
274    let codes = rand::seq::index::sample(
275        &mut xmtp_common::rng(),
276        HANDLE_CODE_COUNT,
277        HANDLE_SEARCH_CODES,
278    );
279    let coded: Vec<Handle> = codes
280        .into_iter()
281        .map(|code| {
282            let code = u16::try_from(code).expect("a sampled code is below 10 000");
283            Handle::with_code(handle.name(), code)
284                .expect("a name part with a four-digit code is a handle")
285        })
286        .collect();
287    std::iter::once(handle).chain(coded).collect()
288}
289
290/// Whether the backend names an inbox for each handle, in order. This
291/// checks no association state: use it only where a wrong answer costs a
292/// candidate, never to reach an inbox.
293pub async fn handles_have_owner<Context: IdentityContext>(
294    context: &Context,
295    handles: &[Handle],
296) -> Result<Vec<bool>, IdentityUpdateError> {
297    Ok(lookup_targets(context.api(), &handle_targets(handles))
298        .await?
299        .iter()
300        .map(Option::is_some)
301        .collect())
302}
303
304/// The handles for `input` that no inbox holds. `input` is a handle as a
305/// user typed it; its ASCII capitals become lowercase. Input with a code
306/// checks only that handle. A name without a code checks the name and
307/// [`HANDLE_SEARCH_CODES`] distinct random codes in one lookup, and the
308/// name comes first in the result. Text outside the handle grammar fails
309/// before any request. No policy applies here, so a returned handle can
310/// still be refused when it is claimed.
311// implements: CONF-081
312pub async fn available_handles<Context: IdentityContext>(
313    context: &Context,
314    input: &str,
315) -> Result<Vec<Handle>, IdentityUpdateError> {
316    let handle = Handle::from_input(input).map_err(HandleError::from)?;
317    let candidates = search_candidates(handle);
318    let owned = handles_have_owner(context, &candidates).await?;
319    Ok(candidates
320        .into_iter()
321        .zip(owned)
322        .filter_map(|(candidate, owned)| (!owned).then_some(candidate))
323        .collect())
324}
325
326/// Claim `handle` for this client's inbox, signed by this installation's
327/// key. A claim of a handle the inbox already holds makes it the display
328/// handle again. The backend refuses a taken, reserved, short, or too
329/// frequent claim with `ApiError::HandleRejected`. After the publish, the
330/// client has stored the inbox's new identity update.
331pub async fn claim_handle<Context: IdentityContext>(
332    context: &Context,
333    handle: &Handle,
334) -> Result<(), IdentityUpdateError> {
335    let identity_updates = IdentityUpdates::new(context);
336    let conn = context.db();
337    load_identity_updates_for_client(context, &conn, &[context.inbox_id()]).await?;
338    let state = identity_updates
339        .get_association_state(&conn, context.inbox_id(), None)
340        .await?;
341    ensure_claim_allowed(&state)?;
342    let request = identity_updates
343        .claim_handle_request(handle.clone())
344        .await?;
345    identity_updates.apply_signature_request(request).await
346}
347
348/// The inbox of each target, in order, from one call. Fails when any
349/// target has no inbox: an account as `find_or_create_dm_by_identity`
350/// does, and a handle with [`HandleError::NotFound`].
351pub async fn resolve_targets<Context: IdentityContext>(
352    context: &Context,
353    targets: &[IdentityTarget],
354) -> Result<Vec<InboxId>, IdentityUpdateError> {
355    let inboxes = resolve_targets_in(context, targets).await?;
356    targets
357        .iter()
358        .zip(inboxes)
359        .map(|(target, inbox)| match (target, inbox) {
360            (_, Some(inbox)) => Ok(inbox),
361            (IdentityTarget::Account(identifier), None) => {
362                Err(NotFound::InboxIdForAddress(identifier.to_string()).into())
363            }
364            (IdentityTarget::Handle(_), None) => Err(HandleError::NotFound.into()),
365        })
366        .collect()
367}