xmtp_id/associations/
builder.rs

1//! Builders for creating a [`SignatureRequest`] with a [`PendingIdentityAction`] for an external SDK/Library, which can then be
2//! resolved into an [`IdentityUpdate`](super::association_log::IdentityUpdate). An [`IdentityUpdate`](super::association_log::IdentityUpdate) may be used for updating the state
3//! of an XMTP ID according to [XIP-46](https://github.com/xmtp/XIPs/pull/53)
4
5use std::collections::HashMap;
6
7use super::member::{HasMemberKind, Identifier};
8use crate::scw_verifier::SmartContractSignatureVerifier;
9use thiserror::Error;
10use xmtp_common::time::now_ns;
11
12use super::{
13    MemberIdentifier, MemberKind, SignatureError,
14    unsigned_actions::{
15        SignatureTextCreator, UnsignedAction, UnsignedAddAssociation,
16        UnsignedChangeRecoveryAddress, UnsignedCreateInbox, UnsignedIdentityUpdate,
17        UnsignedRevokeAssociation,
18    },
19    unverified::{
20        NewUnverifiedSmartContractWalletSignature, UnverifiedAction, UnverifiedAddAssociation,
21        UnverifiedChangeRecoveryAddress, UnverifiedCreateInbox, UnverifiedIdentityUpdate,
22        UnverifiedRevokeAssociation, UnverifiedSignature, UnverifiedSmartContractWalletSignature,
23    },
24    verified_signature::VerifiedSignature,
25};
26
27/// The SignatureField is used to map the signatures from a [SignatureRequest] back to the correct
28/// field in an [IdentityUpdate]. It is used in the `pending_signatures` map in a [PendingIdentityAction]
29#[derive(Clone, PartialEq, Hash, Eq, Debug)]
30enum SignatureField {
31    InitialAddress,
32    ExistingMember,
33    NewMember,
34    RecoveryAddress,
35}
36
37#[derive(Clone, Debug)]
38pub struct PendingIdentityAction {
39    unsigned_action: UnsignedAction,
40    pending_signatures: HashMap<SignatureField, MemberIdentifier>,
41}
42
43/// The SignatureRequestBuilder is used to collect all of the actions in
44/// an IdentityUpdate, but without the signatures.
45/// It outputs a SignatureRequest, which can then collect the relevant signatures and be turned into
46/// an IdentityUpdate.
47pub struct SignatureRequestBuilder {
48    inbox_id: String,
49    client_timestamp_ns: u64,
50    actions: Vec<PendingIdentityAction>,
51}
52
53impl SignatureRequestBuilder {
54    /// Create a new IdentityUpdateBuilder for the given `inbox_id`
55    pub fn new<S: AsRef<str>>(inbox_id: S) -> Self {
56        Self {
57            inbox_id: inbox_id.as_ref().to_string(),
58            client_timestamp_ns: now_ns() as u64,
59            actions: vec![],
60        }
61    }
62
63    /// Create a new inbox. This method must be called before any other methods or the IdentityUpdate will fail
64    pub fn create_inbox(mut self, signer_identity: Identifier, nonce: u64) -> Self {
65        let pending_action = PendingIdentityAction {
66            unsigned_action: UnsignedAction::CreateInbox(UnsignedCreateInbox {
67                account_identifier: signer_identity.clone(),
68                nonce,
69            }),
70            pending_signatures: HashMap::from([(
71                SignatureField::InitialAddress,
72                signer_identity.into(),
73            )]),
74        };
75        // Save the `PendingIdentityAction` for later
76        self.actions.push(pending_action);
77
78        self
79    }
80
81    /// Add an AddAssociation action.
82    pub fn add_association(
83        mut self,
84        new_member_identifier: MemberIdentifier,
85        existing_member_identifier: MemberIdentifier,
86    ) -> Self {
87        self.actions.push(PendingIdentityAction {
88            unsigned_action: UnsignedAction::AddAssociation(UnsignedAddAssociation {
89                new_member_identifier: new_member_identifier.clone(),
90            }),
91            pending_signatures: HashMap::from([
92                (SignatureField::ExistingMember, existing_member_identifier),
93                (SignatureField::NewMember, new_member_identifier),
94            ]),
95        });
96
97        self
98    }
99
100    pub fn revoke_association(
101        mut self,
102        recovery_address_signer: MemberIdentifier,
103        revoked_member: MemberIdentifier,
104    ) -> Self {
105        self.actions.push(PendingIdentityAction {
106            pending_signatures: HashMap::from([(
107                SignatureField::RecoveryAddress,
108                recovery_address_signer,
109            )]),
110            unsigned_action: UnsignedAction::RevokeAssociation(UnsignedRevokeAssociation {
111                revoked_member,
112            }),
113        });
114
115        self
116    }
117
118    pub fn change_recovery_address(
119        mut self,
120        recovery_address_signer: MemberIdentifier,
121        new_recovery_identifier: Identifier,
122    ) -> Self {
123        self.actions.push(PendingIdentityAction {
124            pending_signatures: HashMap::from([(
125                SignatureField::RecoveryAddress,
126                recovery_address_signer,
127            )]),
128            unsigned_action: UnsignedAction::ChangeRecoveryAddress(UnsignedChangeRecoveryAddress {
129                new_recovery_identifier,
130            }),
131        });
132
133        self
134    }
135
136    pub fn build(self) -> SignatureRequest {
137        let unsigned_actions: Vec<UnsignedAction> = self
138            .actions
139            .iter()
140            .map(|pending_action| pending_action.unsigned_action.clone())
141            .collect();
142
143        let signature_text = get_signature_text(
144            unsigned_actions,
145            self.inbox_id.clone(),
146            self.client_timestamp_ns,
147        );
148
149        SignatureRequest::new(
150            self.actions,
151            signature_text,
152            self.inbox_id,
153            self.client_timestamp_ns,
154        )
155    }
156}
157
158#[derive(Debug, Error)]
159pub enum SignatureRequestError {
160    #[error("Unknown signer")]
161    UnknownSigner,
162    #[error("Required signature was not provided")]
163    MissingSigner,
164    #[error("Signature error {0}")]
165    Signature(#[from] SignatureError),
166    #[error("Unable to get block number")]
167    BlockNumber,
168}
169
170/// A signature request is meant to be sent over the FFI barrier (wrapped in a mutex) to platform SDKs.
171/// `xmtp_mls` can add any InstallationKey signatures first, so that the platform SDK does not need to worry about those.
172/// The platform SDK can then fill in any missing signatures and convert it to an IdentityUpdate that is ready to be published
173/// to the network
174#[derive(Clone, Debug)]
175pub struct SignatureRequest {
176    pending_actions: Vec<PendingIdentityAction>,
177    signature_text: String,
178    signatures: HashMap<MemberIdentifier, UnverifiedSignature>,
179    client_timestamp_ns: u64,
180    inbox_id: String,
181}
182
183impl SignatureRequest {
184    pub fn new(
185        pending_actions: Vec<PendingIdentityAction>,
186        signature_text: String,
187        inbox_id: String,
188        client_timestamp_ns: u64,
189    ) -> Self {
190        Self {
191            inbox_id,
192            pending_actions,
193            signature_text,
194            signatures: HashMap::new(),
195            client_timestamp_ns,
196        }
197    }
198
199    pub fn missing_signatures(&self) -> Vec<&MemberIdentifier> {
200        self.pending_actions
201            .iter()
202            .flat_map(|pending_action| pending_action.pending_signatures.values())
203            .filter(|ident| !self.signatures.contains_key(ident))
204            .collect()
205    }
206
207    pub fn missing_address_signatures(&self) -> Vec<&MemberIdentifier> {
208        self.missing_signatures()
209            .into_iter()
210            .filter(|member| matches!(member.kind(), MemberKind::Ethereum | MemberKind::Passkey))
211            .collect()
212    }
213
214    /// Often the front-end doesn't know the current block number when adding a smart contract.
215    /// This is for when you want to add a smart-contract wallet,
216    /// and need the verifier to populate the latest block number for you.
217    pub async fn add_new_unverified_smart_contract_signature(
218        &mut self,
219        mut signature: NewUnverifiedSmartContractWalletSignature,
220        scw_verifier: impl SmartContractSignatureVerifier,
221    ) -> Result<(), SignatureRequestError> {
222        let verified_signature = VerifiedSignature::from_smart_contract_wallet(
223            &self.signature_text,
224            scw_verifier,
225            &signature.signature_bytes,
226            signature.account_id.clone(),
227            &mut signature.block_number,
228        )
229        .await?;
230
231        let Some(block_number) = signature.block_number else {
232            return Err(SignatureRequestError::BlockNumber);
233        };
234
235        self.add_verified_signature(
236            UnverifiedSignature::SmartContractWallet(UnverifiedSmartContractWalletSignature {
237                account_id: signature.account_id,
238                block_number,
239                signature_bytes: signature.signature_bytes,
240            }),
241            verified_signature,
242        )
243    }
244
245    pub async fn add_signature(
246        &mut self,
247        signature: UnverifiedSignature,
248        scw_verifier: impl SmartContractSignatureVerifier,
249    ) -> Result<(), SignatureRequestError> {
250        let verified_signature = signature
251            .to_verified(self.signature_text.clone(), scw_verifier)
252            .await?;
253
254        self.add_verified_signature(signature, verified_signature)
255    }
256
257    fn add_verified_signature(
258        &mut self,
259        signature: UnverifiedSignature,
260        verified_signature: VerifiedSignature,
261    ) -> Result<(), SignatureRequestError> {
262        let signer_identity = &verified_signature.signer;
263
264        let missing_signatures = self.missing_signatures();
265        tracing::info!(
266            signer = %signer_identity,
267            missing_signatures=?missing_signatures,
268            "adding verified signature");
269
270        // Make sure the signer is someone actually in the request
271        if !missing_signatures.contains(&signer_identity) {
272            return Err(SignatureRequestError::UnknownSigner);
273        }
274
275        self.signatures.insert(verified_signature.signer, signature);
276
277        Ok(())
278    }
279
280    pub fn is_ready(&self) -> bool {
281        self.missing_signatures().is_empty()
282    }
283
284    pub fn signature_text(&self) -> String {
285        self.signature_text.clone()
286    }
287
288    pub fn build_identity_update(self) -> Result<UnverifiedIdentityUpdate, SignatureRequestError> {
289        if !self.is_ready() {
290            return Err(SignatureRequestError::MissingSigner);
291        }
292
293        let actions = self
294            .pending_actions
295            .clone()
296            .into_iter()
297            .map(|pending_action| build_action(pending_action, &self.signatures))
298            .collect::<Result<Vec<UnverifiedAction>, SignatureRequestError>>()?;
299
300        Ok(UnverifiedIdentityUpdate::new(
301            self.inbox_id,
302            self.client_timestamp_ns,
303            actions,
304        ))
305    }
306
307    pub fn inbox_id(&self) -> crate::InboxIdRef<'_> {
308        &self.inbox_id
309    }
310}
311
312fn build_action(
313    pending_action: PendingIdentityAction,
314    signatures: &HashMap<MemberIdentifier, UnverifiedSignature>,
315) -> Result<UnverifiedAction, SignatureRequestError> {
316    match pending_action.unsigned_action {
317        UnsignedAction::CreateInbox(unsigned_action) => {
318            let signer_identity = pending_action
319                .pending_signatures
320                .get(&SignatureField::InitialAddress)
321                .ok_or(SignatureRequestError::MissingSigner)?;
322            let initial_identifier_signature = signatures
323                .get(signer_identity)
324                .cloned()
325                .ok_or(SignatureRequestError::MissingSigner)?;
326
327            Ok(UnverifiedAction::CreateInbox(UnverifiedCreateInbox {
328                unsigned_action,
329                initial_identifier_signature,
330            }))
331        }
332        UnsignedAction::AddAssociation(unsigned_action) => {
333            let existing_member_signer_identity = pending_action
334                .pending_signatures
335                .get(&SignatureField::ExistingMember)
336                .ok_or(SignatureRequestError::MissingSigner)?;
337            let new_member_signer_identity = pending_action
338                .pending_signatures
339                .get(&SignatureField::NewMember)
340                .ok_or(SignatureRequestError::MissingSigner)?;
341
342            let existing_member_signature = signatures
343                .get(existing_member_signer_identity)
344                .cloned()
345                .ok_or(SignatureRequestError::MissingSigner)?;
346
347            let new_member_signature = signatures
348                .get(new_member_signer_identity)
349                .cloned()
350                .ok_or(SignatureRequestError::MissingSigner)?;
351
352            Ok(UnverifiedAction::AddAssociation(UnverifiedAddAssociation {
353                unsigned_action,
354                existing_member_signature,
355                new_member_signature,
356            }))
357        }
358        UnsignedAction::RevokeAssociation(unsigned_action) => {
359            let signer_identity = pending_action
360                .pending_signatures
361                .get(&SignatureField::RecoveryAddress)
362                .ok_or(SignatureRequestError::MissingSigner)?;
363            let recovery_address_signature = signatures
364                .get(signer_identity)
365                .cloned()
366                .ok_or(SignatureRequestError::MissingSigner)?;
367
368            Ok(UnverifiedAction::RevokeAssociation(
369                UnverifiedRevokeAssociation {
370                    recovery_identifier_signature: recovery_address_signature,
371                    unsigned_action,
372                },
373            ))
374        }
375        UnsignedAction::ChangeRecoveryAddress(unsigned_action) => {
376            let signer_identity = pending_action
377                .pending_signatures
378                .get(&SignatureField::RecoveryAddress)
379                .ok_or(SignatureRequestError::MissingSigner)?;
380
381            let recovery_identifier_signature = signatures
382                .get(signer_identity)
383                .cloned()
384                .ok_or(SignatureRequestError::MissingSigner)?;
385
386            Ok(UnverifiedAction::ChangeRecoveryAddress(
387                UnverifiedChangeRecoveryAddress {
388                    recovery_identifier_signature,
389                    unsigned_action,
390                },
391            ))
392        }
393    }
394}
395
396fn get_signature_text(
397    actions: Vec<UnsignedAction>,
398    inbox_id: String,
399    client_timestamp_ns: u64,
400) -> String {
401    let identity_update = UnsignedIdentityUpdate {
402        client_timestamp_ns,
403        actions,
404        inbox_id,
405    };
406
407    identity_update.signature_text()
408}
409
410#[cfg(test)]
411pub(crate) mod tests {
412    #[cfg(target_arch = "wasm32")]
413    wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_dedicated_worker);
414    use alloy::signers::{Signer, local::PrivateKeySigner};
415    use xmtp_cryptography::XmtpInstallationCredential;
416
417    use crate::{
418        InboxOwner,
419        associations::{
420            IdentityUpdate, get_state,
421            test_utils::{
422                MockSmartContractSignatureVerifier, WalletTestExt, add_installation_key_signature,
423                add_wallet_signature,
424            },
425            unverified::UnverifiedRecoverableEcdsaSignature,
426        },
427    };
428
429    use super::*;
430
431    async fn convert_to_verified(identity_update: &UnverifiedIdentityUpdate) -> IdentityUpdate {
432        let scw_verifier = MockSmartContractSignatureVerifier::new(false);
433        identity_update
434            .to_verified(&scw_verifier)
435            .await
436            .expect("should be valid")
437    }
438
439    #[xmtp_common::test]
440    async fn create_inbox() {
441        let wallet = PrivateKeySigner::random();
442        let account_ident = wallet.get_identifier().unwrap();
443        let nonce = 0;
444        let inbox_id = wallet.get_inbox_id(nonce);
445
446        let mut signature_request = SignatureRequestBuilder::new(inbox_id)
447            .create_inbox(account_ident, nonce)
448            .build();
449
450        add_wallet_signature(&mut signature_request, &wallet).await;
451
452        let identity_update = signature_request
453            .build_identity_update()
454            .expect("should be valid");
455
456        get_state(vec![convert_to_verified(&identity_update).await]).expect("should be valid");
457    }
458
459    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
460    #[cfg_attr(not(target_arch = "wasm32"), tokio::test)]
461    async fn create_and_add_identity() {
462        let wallet = PrivateKeySigner::random();
463        let installation_key = XmtpInstallationCredential::new();
464        let account_address = wallet.get_identifier().unwrap();
465        let nonce = 0;
466        let inbox_id = wallet.get_inbox_id(nonce);
467        let ident = Identifier::eth(&account_address).unwrap();
468        let new_member_identifier =
469            MemberIdentifier::installation(installation_key.public_bytes().to_vec());
470
471        let mut signature_request = SignatureRequestBuilder::new(inbox_id)
472            .create_inbox(ident.clone(), nonce)
473            .add_association(new_member_identifier, ident.into())
474            .build();
475
476        add_wallet_signature(&mut signature_request, &wallet).await;
477        add_installation_key_signature(&mut signature_request, &installation_key).await;
478
479        let identity_update = signature_request
480            .build_identity_update()
481            .expect("should be valid");
482
483        let state =
484            get_state(vec![convert_to_verified(&identity_update).await]).expect("should be valid");
485        assert_eq!(state.members().len(), 2);
486    }
487
488    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
489    #[cfg_attr(not(target_arch = "wasm32"), tokio::test)]
490    async fn create_and_revoke() {
491        let wallet = PrivateKeySigner::random();
492        let nonce = 0;
493        let inbox_id = wallet.get_inbox_id(nonce);
494        let existing_member_identifier = wallet.identifier();
495
496        let mut signature_request = SignatureRequestBuilder::new(inbox_id)
497            .create_inbox(existing_member_identifier.clone(), nonce)
498            .revoke_association(
499                existing_member_identifier.clone().into(),
500                existing_member_identifier.clone().into(),
501            )
502            .build();
503
504        add_wallet_signature(&mut signature_request, &wallet).await;
505
506        let identity_update = signature_request
507            .build_identity_update()
508            .expect("should be valid");
509
510        let state =
511            get_state(vec![convert_to_verified(&identity_update).await]).expect("should be valid");
512
513        assert_eq!(state.members().len(), 0);
514    }
515
516    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
517    #[cfg_attr(not(target_arch = "wasm32"), tokio::test)]
518    async fn attempt_adding_unknown_signer() {
519        let account_address = "0x1234567890abcdef1234567890abcdef12345678".to_string();
520        let nonce = 0;
521        let ident = Identifier::eth(&account_address).unwrap();
522        let inbox_id = ident.inbox_id(nonce).unwrap();
523
524        let mut signature_request = SignatureRequestBuilder::new(inbox_id)
525            .create_inbox(ident, nonce)
526            .build();
527
528        let rand_wallet = PrivateKeySigner::random();
529
530        let signature_text = signature_request.signature_text();
531        let sig = rand_wallet
532            .sign_message(signature_text.as_bytes())
533            .await
534            .unwrap();
535        let unverified_sig = UnverifiedSignature::RecoverableEcdsa(
536            UnverifiedRecoverableEcdsaSignature::new(sig.into()),
537        );
538        let scw_verifier = MockSmartContractSignatureVerifier::new(false);
539
540        let attempt_to_add_random_member = signature_request
541            .add_signature(unverified_sig, &scw_verifier)
542            .await;
543
544        assert!(matches!(
545            attempt_to_add_random_member,
546            Err(SignatureRequestError::UnknownSigner)
547        ));
548    }
549}