core_crypto/mls/conversation/
renew.rs

1use core_crypto_keystore::entities::MlsEncryptionKeyPair;
2use openmls::prelude::{LeafNode, LeafNodeIndex, Proposal, QueuedProposal, Sender, StagedCommit};
3use openmls_traits::OpenMlsCryptoProvider;
4
5use mls_crypto_provider::MlsCryptoProvider;
6
7use super::{Error, Result};
8use crate::{
9    KeystoreError, RecursiveError,
10    prelude::{MlsConversation, MlsProposalBundle, Session},
11};
12
13/// Marker struct holding methods responsible for restoring (renewing) proposals (or pending commit)
14/// in case another commit has been accepted by the backend instead of ours
15pub(crate) struct Renew;
16
17impl Renew {
18    /// Renews proposals:
19    /// * in pending_proposals but not in valid commit
20    /// * in pending_commit but not in valid commit
21    ///
22    /// NB: we do not deal with partial commit (commit which do not contain all pending proposals)
23    /// because they cannot be created at the moment by core-crypto
24    ///
25    /// * `self_index` - own client [KeyPackageRef] in current MLS group
26    /// * `pending_proposals` - local pending proposals in group's proposal store
27    /// * `pending_commit` - local pending commit which is now invalid
28    /// * `valid_commit` - commit accepted by the backend which will now supersede our local pending commit
29    pub(crate) fn renew<'a>(
30        self_index: &LeafNodeIndex,
31        pending_proposals: impl Iterator<Item = QueuedProposal> + 'a,
32        pending_commit: Option<&'a StagedCommit>,
33        valid_commit: &'a StagedCommit,
34    ) -> (Vec<QueuedProposal>, bool) {
35        // indicates if we need to renew an update proposal.
36        // true only if we have an empty pending commit or the valid commit does not contain one of our update proposal
37        // otherwise, local orphan update proposal will be renewed regularly, without this flag
38        let mut needs_update = false;
39
40        let renewed_pending_proposals = if let Some(pending_commit) = pending_commit {
41            // present in pending commit but not in valid commit
42            let commit_proposals = pending_commit.queued_proposals().cloned().collect::<Vec<_>>();
43
44            // if our own pending commit is empty it means we were attempting to update
45            let empty_commit = commit_proposals.is_empty();
46
47            // does the valid commit contains one of our update proposal ?
48            let valid_commit_has_own_update_proposal = valid_commit.update_proposals().any(|p| match p.sender() {
49                Sender::Member(sender_index) => self_index == sender_index,
50                _ => false,
51            });
52
53            // do we need to renew the update or has it already been committed
54            needs_update = !valid_commit_has_own_update_proposal && empty_commit;
55
56            // local proposals present in local pending commit but not in valid commit
57            commit_proposals
58                .into_iter()
59                .filter_map(|p| Self::is_proposal_renewable(p, Some(valid_commit)))
60                .collect::<Vec<_>>()
61        } else {
62            // local pending proposals present locally but not in valid commit
63            pending_proposals
64                .filter_map(|p| Self::is_proposal_renewable(p, Some(valid_commit)))
65                .collect::<Vec<_>>()
66        };
67        (renewed_pending_proposals, needs_update)
68    }
69
70    /// A proposal has to be renewed if it is absent from supplied commit
71    fn is_proposal_renewable(proposal: QueuedProposal, commit: Option<&StagedCommit>) -> Option<QueuedProposal> {
72        if let Some(commit) = commit {
73            let in_commit = match proposal.proposal() {
74                Proposal::Add(add) => commit.add_proposals().any(|p| {
75                    let commits_identity = p.add_proposal().key_package().leaf_node().credential().identity();
76                    let proposal_identity = add.key_package().leaf_node().credential().identity();
77                    commits_identity == proposal_identity
78                }),
79                Proposal::Remove(remove) => commit
80                    .remove_proposals()
81                    .any(|p| p.remove_proposal().removed() == remove.removed()),
82                Proposal::Update(update) => commit
83                    .update_proposals()
84                    .any(|p| p.update_proposal().leaf_node() == update.leaf_node()),
85                _ => true,
86            };
87            if in_commit { None } else { Some(proposal) }
88        } else {
89            // if proposal is orphan (not present in commit)
90            Some(proposal)
91        }
92    }
93}
94
95impl MlsConversation {
96    /// Given the proposals to renew, actually restore them by using associated methods in [MlsGroup].
97    /// This will also add them to the local proposal store
98    pub(crate) async fn renew_proposals_for_current_epoch(
99        &mut self,
100        client: &Session,
101        backend: &MlsCryptoProvider,
102        proposals: impl Iterator<Item = QueuedProposal>,
103        needs_update: bool,
104    ) -> Result<Vec<MlsProposalBundle>> {
105        let mut bundle = vec![];
106        let is_external = |p: &QueuedProposal| matches!(p.sender(), Sender::External(_) | Sender::NewMemberProposal);
107        let proposals = proposals.filter(|p| !is_external(p));
108        for proposal in proposals {
109            let msg = match proposal.proposal {
110                Proposal::Add(add) => self.propose_add_member(client, backend, add.key_package.into()).await?,
111                Proposal::Remove(remove) => self.propose_remove_member(client, backend, remove.removed()).await?,
112                Proposal::Update(update) => self.renew_update(client, backend, Some(update.leaf_node())).await?,
113                _ => return Err(Error::ProposalVariantCannotBeRenewed),
114            };
115            bundle.push(msg);
116        }
117        if needs_update {
118            let proposal = self.renew_update(client, backend, None).await?;
119            bundle.push(proposal);
120        }
121        Ok(bundle)
122    }
123
124    /// Renews an update proposal by considering the explicit LeafNode supplied in the proposal
125    /// by applying it to the current own LeafNode.
126    /// At this point, we have already verified we are only operating on proposals created by self.
127    async fn renew_update(
128        &mut self,
129        client: &Session,
130        backend: &MlsCryptoProvider,
131        leaf_node: Option<&LeafNode>,
132    ) -> Result<MlsProposalBundle> {
133        if let Some(leaf_node) = leaf_node {
134            // Creating an update rekeys the LeafNode everytime. Hence we need to clear the previous
135            // encryption key from the keystore otherwise we would have a leak
136            backend
137                .key_store()
138                .remove::<MlsEncryptionKeyPair, _>(leaf_node.encryption_key().as_slice())
139                .await
140                .map_err(KeystoreError::wrap("removing mls encryption keypair"))?;
141        }
142
143        let mut leaf_node = leaf_node
144            .or_else(|| self.group.own_leaf())
145            .cloned()
146            .ok_or(Error::MlsGroupInvalidState("own_leaf is None"))?;
147
148        let sc = self.signature_scheme();
149        let ct = self.own_credential_type()?;
150        let cb = client
151            .find_most_recent_credential_bundle(sc, ct)
152            .await
153            .map_err(RecursiveError::mls_client("finding most recent credential bundle"))?;
154
155        leaf_node.set_credential_with_key(cb.to_mls_credential_with_key());
156
157        self.propose_explicit_self_update(client, backend, Some(leaf_node))
158            .await
159    }
160
161    pub(crate) fn self_pending_proposals(&self) -> impl Iterator<Item = &QueuedProposal> {
162        self.group
163            .pending_proposals()
164            .filter(|&p| matches!(p.sender(), Sender::Member(i) if i == &self.group.own_leaf_index()))
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use wasm_bindgen_test::*;
171
172    use crate::test_utils::*;
173
174    mod update {
175        use super::*;
176
177        #[apply(all_cred_cipher)]
178        #[wasm_bindgen_test]
179        pub async fn renewable_when_created_by_self(case: TestContext) {
180            let [alice, bob] = case.sessions().await;
181            Box::pin(async move {
182                let conversation = case.create_conversation([&alice, &bob]).await;
183                let id = conversation.id().clone();
184
185                assert!(alice.pending_proposals(&id).await.is_empty());
186                let propposal_guard = conversation.update_proposal().await;
187                assert_eq!(alice.pending_proposals(&id).await.len(), 1);
188
189                // Bob hasn't Alice's proposal but creates a commit
190                let (commit_guard, result) = propposal_guard
191                    .finish()
192                    .acting_as(&bob)
193                    .await
194                    .update()
195                    .await
196                    .notify_member_fallible(&alice)
197                    .await;
198
199                let proposals = result.unwrap().proposals;
200                // Alice should renew the proposal because its hers
201                assert_eq!(alice.pending_proposals(&id).await.len(), 1);
202                assert_eq!(proposals.len(), alice.pending_proposals(&id).await.len());
203
204                // It should also renew the proposal when in pending_commit
205                let (_, result) = commit_guard
206                    .finish()
207                    .acting_as(&bob)
208                    .await
209                    .update()
210                    .await
211                    .notify_member_fallible(&alice)
212                    .await;
213                let proposals = result.unwrap().proposals;
214                // Alice should renew the proposal because its hers
215                // It should also replace existing one
216                assert_eq!(alice.pending_proposals(&id).await.len(), 1);
217                assert_eq!(proposals.len(), alice.pending_proposals(&id).await.len());
218            })
219            .await
220        }
221
222        #[apply(all_cred_cipher)]
223        #[wasm_bindgen_test]
224        pub async fn not_renewable_when_in_valid_commit(case: TestContext) {
225            let [alice, bob] = case.sessions().await;
226            Box::pin(async move {
227                let conversation = case.create_conversation([&alice, &bob]).await;
228                let id = conversation.id().clone();
229
230                assert!(alice.pending_proposals(&id).await.is_empty());
231                // Bob has Alice's update proposal
232                let conversation = conversation.update_proposal_notify().await;
233                assert_eq!(alice.pending_proposals(&id).await.len(), 1);
234
235                // Bob's commit has Alice's proposal
236                let (commit_guard, result) = conversation
237                    .acting_as(&bob)
238                    .await
239                    .update()
240                    .await
241                    .notify_member_fallible(&alice)
242                    .await;
243
244                let proposals = result.unwrap().proposals;
245                // Alice proposal should not be renew as it was in valid commit
246                assert!(alice.pending_proposals(&id).await.is_empty());
247                assert_eq!(proposals.len(), alice.pending_proposals(&id).await.len());
248
249                let (_, result) = commit_guard
250                    .finish()
251                    .update_proposal_notify()
252                    .await
253                    .acting_as(&bob)
254                    .await
255                    .update()
256                    .await
257                    .notify_member_fallible(&alice)
258                    .await;
259                let proposals = result.unwrap().proposals;
260                // Alice should not be renew as it was in valid commit
261                assert!(alice.pending_proposals(&id).await.is_empty());
262                assert_eq!(proposals.len(), alice.pending_proposals(&id).await.len());
263            })
264            .await
265        }
266
267        #[apply(all_cred_cipher)]
268        #[wasm_bindgen_test]
269        pub async fn not_renewable_by_ref(case: TestContext) {
270            let [alice, bob, charlie] = case.sessions().await;
271            Box::pin(async move {
272                let conversation = case.create_conversation([&alice, &bob, &charlie]).await;
273                let id = conversation.id().clone();
274
275                let propposal_guard = conversation.acting_as(&bob).await.update_proposal().await;
276                assert!(alice.pending_proposals(&id).await.is_empty());
277                let propposal_guard = propposal_guard.notify_member(&alice).await;
278                assert_eq!(alice.pending_proposals(&id).await.len(), 1);
279
280                // Charlie hasn't been notified about Bob's proposal but creates a commit
281                let (_, result) = propposal_guard
282                    .finish()
283                    .acting_as(&charlie)
284                    .await
285                    .update()
286                    .await
287                    .notify_member_fallible(&alice)
288                    .await;
289                let proposals = result.unwrap().proposals;
290                // Alice should not renew Bob's update proposal
291                assert!(alice.pending_proposals(&id).await.is_empty());
292                assert_eq!(proposals.len(), alice.pending_proposals(&id).await.len());
293            })
294            .await
295        }
296    }
297
298    mod add {
299        use super::*;
300
301        #[apply(all_cred_cipher)]
302        #[wasm_bindgen_test]
303        pub async fn not_renewable_when_valid_commit_adds_same(case: TestContext) {
304            let [alice, bob, charlie] = case.sessions().await;
305            Box::pin(async move {
306                let conversation = case.create_conversation([&alice, &bob]).await;
307                let id = conversation.id().clone();
308
309                // Alice creates a proposal locally that nobody will be notified about
310                assert!(alice.pending_proposals(&id).await.is_empty());
311                let proposal_guard = conversation.invite_proposal(&charlie).await;
312                assert_eq!(alice.pending_proposals(&id).await.len(), 1);
313
314                // Bob commits the same invite that alice proposed
315                let (commit_guard, result) = proposal_guard
316                    .finish()
317                    .acting_as(&bob)
318                    .await
319                    .invite([&charlie])
320                    .await
321                    .notify_member_fallible(&alice)
322                    .await;
323
324                let proposals = result.unwrap().proposals;
325                // Alice proposal is not renewed since she also wanted to add Charlie
326                assert!(alice.pending_proposals(&id).await.is_empty());
327                assert_eq!(proposals.len(), alice.pending_proposals(&id).await.len());
328
329                let conversation = commit_guard.notify_members().await;
330                assert!(conversation.is_functional_and_contains([&alice, &bob, &charlie]).await);
331            })
332            .await
333        }
334
335        #[apply(all_cred_cipher)]
336        #[wasm_bindgen_test]
337        pub async fn not_renewable_in_pending_commit_when_valid_commit_adds_same(case: TestContext) {
338            let [alice, bob, charlie] = case.sessions().await;
339            Box::pin(async move {
340                let conversation = case.create_conversation([&alice, &bob]).await;
341                let id = conversation.id().clone();
342
343                // Alice creates a proposal locally that nobody will be notified about
344                assert!(alice.pending_proposals(&id).await.is_empty());
345                let proposal_guard = conversation.invite_proposal(&charlie).await;
346                assert_eq!(alice.pending_proposals(&id).await.len(), 1);
347
348                // Here Alice also creates a commit
349                alice.commit_pending_proposals_unmerged(&id).await;
350                assert!(alice.pending_commit(&id).await.is_some());
351
352                // Bob commits the same invite that alice proposed
353                let (commit_guard, result) = proposal_guard
354                    .finish()
355                    .acting_as(&bob)
356                    .await
357                    .invite([&charlie])
358                    .await
359                    .notify_member_fallible(&alice)
360                    .await;
361
362                let proposals = result.unwrap().proposals;
363                // Alice proposal is not renewed since she also wanted to add Charlie
364                assert!(alice.pending_proposals(&id).await.is_empty());
365                assert_eq!(proposals.len(), alice.pending_proposals(&id).await.len());
366
367                let conversation = commit_guard.notify_members().await;
368                assert!(conversation.is_functional_and_contains([&alice, &bob, &charlie]).await);
369            })
370            .await
371        }
372
373        #[apply(all_cred_cipher)]
374        #[wasm_bindgen_test]
375        pub async fn not_renewable_by_ref(case: TestContext) {
376            let [alice, bob, charlie, debbie] = case.sessions().await;
377            Box::pin(async move {
378                let conversation = case.create_conversation([&alice, &bob, &charlie]).await;
379                let id = conversation.id().clone();
380
381                // Bob will propose adding Debbie
382                let proposal_guard = conversation
383                    .acting_as(&bob)
384                    .await
385                    .invite_proposal(&debbie)
386                    .await
387                    .notify_member(&alice)
388                    .await;
389                assert_eq!(alice.pending_proposals(&id).await.len(), 1);
390
391                // But Charlie will commit meanwhile
392                let (_, result) = proposal_guard
393                    .finish()
394                    .acting_as(&charlie)
395                    .await
396                    .update()
397                    .await
398                    .notify_member_fallible(&alice)
399                    .await;
400
401                let proposals = result.unwrap().proposals;
402                // which Alice should not renew since it's not hers
403                assert!(alice.pending_proposals(&id).await.is_empty());
404                assert_eq!(proposals.len(), alice.pending_proposals(&id).await.len());
405            })
406            .await
407        }
408
409        #[apply(all_cred_cipher)]
410        #[wasm_bindgen_test]
411        pub async fn renewable_when_valid_commit_doesnt_adds_same(case: TestContext) {
412            let [alice, bob, charlie] = case.sessions().await;
413            Box::pin(async move {
414                let conversation = case.create_conversation([&alice, &bob]).await;
415                let id = conversation.id().clone();
416
417                // Alice proposes adding Charlie
418                assert!(alice.pending_proposals(&id).await.is_empty());
419                let propposal_guard = conversation.invite_proposal(&charlie).await;
420                assert_eq!(alice.pending_proposals(&id).await.len(), 1);
421
422                // But meanwhile Bob will create a commit without Alice's proposal
423                let (commit_guard, result) = propposal_guard
424                    .finish()
425                    .acting_as(&bob)
426                    .await
427                    .update()
428                    .await
429                    .notify_member_fallible(&alice)
430                    .await;
431
432                let proposals = result.unwrap().proposals;
433                // So Alice proposal should be renewed
434                assert_eq!(alice.pending_proposals(&id).await.len(), 1);
435                assert_eq!(proposals.len(), alice.pending_proposals(&id).await.len());
436
437                // And same should happen when proposal is in pending commit
438                alice.commit_pending_proposals_unmerged(&id).await;
439                assert!(alice.pending_commit(&id).await.is_some());
440                let (_, result) = commit_guard
441                    .finish()
442                    .acting_as(&bob)
443                    .await
444                    .update()
445                    .await
446                    .notify_member_fallible(&alice)
447                    .await;
448
449                let proposals = result.unwrap().proposals;
450                // So Alice proposal should also be renewed
451                // It should also replace existing one
452                assert_eq!(alice.pending_proposals(&id).await.len(), 1);
453                assert_eq!(proposals.len(), alice.pending_proposals(&id).await.len());
454            })
455            .await
456        }
457
458        #[apply(all_cred_cipher)]
459        #[wasm_bindgen_test]
460        pub async fn renews_pending_commit_when_valid_commit_doesnt_add_same(case: TestContext) {
461            let [alice, bob] = case.sessions().await;
462            Box::pin(async move {
463                let conversation = case.create_conversation([&alice, &bob]).await;
464                let id = conversation.id().clone();
465
466                // Alice has a pending commit
467                alice.create_unmerged_commit(&id).await;
468                assert!(alice.pending_commit(&id).await.is_some());
469
470                // But meanwhile Bob will create a commit
471                let (_, result) = conversation
472                    .acting_as(&bob)
473                    .await
474                    .update()
475                    .await
476                    .notify_member_fallible(&alice)
477                    .await;
478
479                let proposals = result.unwrap().proposals;
480                // So Alice proposal should be renewed
481                assert_eq!(alice.pending_proposals(&id).await.len(), 1);
482                assert_eq!(proposals.len(), alice.pending_proposals(&id).await.len());
483            })
484            .await
485        }
486    }
487
488    mod remove {
489        use super::*;
490
491        #[apply(all_cred_cipher)]
492        #[wasm_bindgen_test]
493        pub async fn not_renewable_when_valid_commit_removes_same(case: TestContext) {
494            let [alice, bob, charlie] = case.sessions().await;
495            Box::pin(async move {
496                let conversation = case.create_conversation([&alice, &bob, &charlie]).await;
497                let id = conversation.id().clone();
498
499                assert!(alice.pending_proposals(&id).await.is_empty());
500                let proposal_guard = conversation.remove_proposal(&charlie).await;
501                assert_eq!(alice.pending_proposals(&id).await.len(), 1);
502
503                let (commit_guard, result) = proposal_guard
504                    .finish()
505                    .acting_as(&bob)
506                    .await
507                    .remove(&charlie)
508                    .await
509                    .notify_member_fallible(&alice)
510                    .await;
511
512                let proposals = result.unwrap().proposals;
513                // Remove proposal is not renewed since commit does same
514                assert!(alice.pending_proposals(&id).await.is_empty());
515                assert_eq!(proposals.len(), alice.pending_proposals(&id).await.len());
516
517                let conversation = commit_guard.notify_members().await;
518                assert!(conversation.is_functional_and_contains([&alice, &bob]).await);
519            })
520            .await
521        }
522
523        #[apply(all_cred_cipher)]
524        #[wasm_bindgen_test]
525        pub async fn not_renewable_by_ref(case: TestContext) {
526            let [alice, bob, charlie] = case.sessions().await;
527            Box::pin(async move {
528                let conversation = case.create_conversation([&alice, &bob, &charlie]).await;
529                let id = conversation.id().clone();
530
531                assert!(alice.pending_proposals(&id).await.is_empty());
532                let proposal_guard = conversation
533                    .acting_as(&bob)
534                    .await
535                    .remove_proposal(&charlie)
536                    .await
537                    .notify_member(&alice)
538                    .await;
539                assert_eq!(alice.pending_proposals(&id).await.len(), 1);
540
541                let (_, result) = proposal_guard
542                    .finish()
543                    .acting_as(&charlie)
544                    .await
545                    .update()
546                    .await
547                    .notify_member_fallible(&alice)
548                    .await;
549
550                let proposals = result.unwrap().proposals;
551                // Remove proposal is not renewed since by ref
552                assert!(alice.pending_proposals(&id).await.is_empty());
553                assert_eq!(proposals.len(), alice.pending_proposals(&id).await.len());
554            })
555            .await
556        }
557
558        #[apply(all_cred_cipher)]
559        #[wasm_bindgen_test]
560        pub async fn renewable_when_valid_commit_doesnt_remove_same(case: TestContext) {
561            let [alice, bob, charlie, debbie] = case.sessions().await;
562            Box::pin(async move {
563                let conversation = case.create_conversation([&alice, &bob, &charlie, &debbie]).await;
564                let id = conversation.id().clone();
565
566                // Alice wants to remove Charlie
567                assert!(alice.pending_proposals(&id).await.is_empty());
568                let propposal_guard = conversation.remove_proposal(&charlie).await;
569                assert_eq!(alice.pending_proposals(&id).await.len(), 1);
570
571                // Whereas Bob wants to remove Debbie
572                let (_, result) = propposal_guard
573                    .finish()
574                    .acting_as(&bob)
575                    .await
576                    .remove(&debbie)
577                    .await
578                    .notify_member_fallible(&alice)
579                    .await;
580
581                let proposals = result.unwrap().proposals;
582                // Remove is renewed since valid commit removes another
583                assert_eq!(alice.pending_proposals(&id).await.len(), 1);
584                assert_eq!(proposals.len(), alice.pending_proposals(&id).await.len());
585            })
586            .await
587        }
588
589        #[apply(all_cred_cipher)]
590        #[wasm_bindgen_test]
591        pub async fn renews_pending_commit_when_commit_doesnt_remove_same(case: TestContext) {
592            let [alice, bob, charlie, debbie] = case.sessions().await;
593            Box::pin(async move {
594                let conversation = case.create_conversation([&alice, &bob, &charlie, &debbie]).await;
595                let id = conversation.id().clone();
596
597                // Alice wants to remove Charlie
598                assert!(alice.pending_proposals(&id).await.is_empty());
599                let propposal_guard = conversation.remove_proposal(&charlie).await;
600                assert_eq!(alice.pending_proposals(&id).await.len(), 1);
601                alice.commit_pending_proposals_unmerged(&id).await;
602                assert!(alice.pending_commit(&id).await.is_some());
603
604                // Whereas Bob wants to remove Debbie
605                let (_, result) = propposal_guard
606                    .finish()
607                    .acting_as(&bob)
608                    .await
609                    .remove(&debbie)
610                    .await
611                    .notify_member_fallible(&alice)
612                    .await;
613                let proposals = result.unwrap().proposals;
614                // Remove is renewed since valid commit removes another
615                assert_eq!(alice.pending_proposals(&id).await.len(), 1);
616                assert_eq!(proposals.len(), alice.pending_proposals(&id).await.len());
617            })
618            .await
619        }
620
621        #[apply(all_cred_cipher)]
622        #[wasm_bindgen_test]
623        pub async fn renews_pending_commit_from_proposal_when_commit_doesnt_remove_same(case: TestContext) {
624            let [alice, bob, charlie, debbie] = case.sessions().await;
625            Box::pin(async move {
626                let conversation = case.create_conversation([&alice, &bob, &charlie, &debbie]).await;
627                let id = conversation.id().clone();
628
629                // Alice wants to remove Charlie
630                let propposal_guard = conversation.remove_proposal(&charlie).await;
631                alice.commit_pending_proposals_unmerged(&id).await;
632
633                // Whereas Bob wants to remove Debbie
634                let (_, result) = propposal_guard
635                    .finish()
636                    .acting_as(&bob)
637                    .await
638                    .remove(&debbie)
639                    .await
640                    .notify_member_fallible(&alice)
641                    .await;
642                let proposals = result.unwrap().proposals;
643                // Remove is renewed since valid commit removes another
644                assert_eq!(alice.pending_proposals(&id).await.len(), 1);
645                assert_eq!(proposals.len(), alice.pending_proposals(&id).await.len());
646            })
647            .await
648        }
649    }
650}