1use alloc::boxed::Box;
66use alloc::collections::{BTreeMap, BTreeSet};
67use alloc::sync::Arc;
68use alloc::vec::Vec;
69
70use miden_protocol::account::{Account, AccountCode, AccountId};
71use miden_protocol::asset::NonFungibleAsset;
72use miden_protocol::block::BlockNumber;
73use miden_protocol::errors::AssetError;
74use miden_protocol::note::{Note, NoteDetails, NoteId, NoteRecipient, NoteScript, NoteTag};
75use miden_protocol::transaction::AccountInputs;
76use miden_protocol::{EMPTY_WORD, Felt, Word};
77use miden_standards::account::interface::AccountInterfaceExt;
78use miden_tx::{DataStore, NoteConsumptionChecker, TransactionExecutor};
79use tracing::info;
80
81use super::Client;
82use crate::ClientError;
83use crate::note::NoteUpdateTracker;
84use crate::rpc::domain::account::AccountStorageRequirements;
85use crate::rpc::{AccountStateAt, GrpcError, NodeRpcClient, RpcError};
86use crate::store::data_store::ClientDataStore;
87use crate::store::input_note_states::ExpectedNoteState;
88use crate::store::{
89 InputNoteRecord,
90 InputNoteState,
91 NoteFilter,
92 OutputNoteRecord,
93 Store,
94 TransactionFilter,
95};
96use crate::sync::NoteTagRecord;
97
98mod prover;
99pub use prover::TransactionProver;
100
101mod record;
102pub use record::{
103 DiscardCause,
104 TransactionDetails,
105 TransactionRecord,
106 TransactionStatus,
107 TransactionStatusVariant,
108};
109
110mod store_update;
111pub use store_update::TransactionStoreUpdate;
112
113mod request;
114pub use request::{
115 ForeignAccount,
116 NoteArgs,
117 PaymentNoteDescription,
118 SwapTransactionData,
119 TransactionRequest,
120 TransactionRequestBuilder,
121 TransactionRequestError,
122 TransactionScriptTemplate,
123};
124
125mod result;
126pub use miden_protocol::transaction::{
129 ExecutedTransaction,
130 InputNote,
131 InputNotes,
132 OutputNote,
133 OutputNotes,
134 ProvenTransaction,
135 PublicOutputNote,
136 RawOutputNote,
137 RawOutputNotes,
138 TransactionArgs,
139 TransactionId,
140 TransactionInputs,
141 TransactionKernel,
142 TransactionScript,
143 TransactionSummary,
144};
145pub use miden_protocol::vm::{AdviceInputs, AdviceMap};
146pub use miden_standards::account::interface::{AccountComponentInterface, AccountInterface};
147pub use miden_tx::auth::TransactionAuthenticator;
148pub use miden_tx::{
149 DataStoreError,
150 LocalTransactionProver,
151 ProvingOptions,
152 TransactionExecutorError,
153 TransactionProverError,
154};
155pub use result::TransactionResult;
156
157impl<AUTH> Client<AUTH>
159where
160 AUTH: TransactionAuthenticator + Sync + 'static,
161{
162 pub async fn get_transactions(
167 &self,
168 filter: TransactionFilter,
169 ) -> Result<Vec<TransactionRecord>, ClientError> {
170 self.store.get_transactions(filter).await.map_err(Into::into)
171 }
172
173 pub async fn submit_new_transaction(
186 &mut self,
187 account_id: AccountId,
188 transaction_request: TransactionRequest,
189 ) -> Result<TransactionId, ClientError> {
190 let prover = self.tx_prover.clone();
191 self.submit_new_transaction_with_prover(account_id, transaction_request, prover)
192 .await
193 }
194
195 pub async fn submit_new_transaction_with_prover(
206 &mut self,
207 account_id: AccountId,
208 transaction_request: TransactionRequest,
209 tx_prover: Arc<dyn TransactionProver>,
210 ) -> Result<TransactionId, ClientError> {
211 if !transaction_request.expected_ntx_scripts().is_empty() {
214 Box::pin(self.ensure_ntx_scripts_registered(
215 account_id,
216 transaction_request.expected_ntx_scripts(),
217 tx_prover.clone(),
218 ))
219 .await?;
220 }
221
222 let tx_result = self.execute_transaction(account_id, transaction_request).await?;
223 let tx_id = tx_result.executed_transaction().id();
224
225 let proven_transaction = self.prove_transaction_with(&tx_result, tx_prover).await?;
226 let submission_height =
227 self.submit_proven_transaction(proven_transaction, &tx_result).await?;
228
229 self.apply_transaction(&tx_result, submission_height).await?;
230
231 Ok(tx_id)
232 }
233
234 pub async fn execute_transaction(
248 &mut self,
249 account_id: AccountId,
250 transaction_request: TransactionRequest,
251 ) -> Result<TransactionResult, ClientError> {
252 self.validate_request(account_id, &transaction_request).await?;
254
255 let mut stored_note_records = self
257 .store
258 .get_input_notes(NoteFilter::List(transaction_request.input_note_ids().collect()))
259 .await?;
260
261 for note in &stored_note_records {
263 if note.is_consumed() {
264 return Err(ClientError::TransactionRequestError(
265 TransactionRequestError::InputNoteAlreadyConsumed(note.id()),
266 ));
267 }
268 }
269
270 stored_note_records.retain(InputNoteRecord::is_authenticated);
272
273 let authenticated_note_ids =
274 stored_note_records.iter().map(InputNoteRecord::id).collect::<Vec<_>>();
275
276 let unauthenticated_input_notes = transaction_request
281 .input_notes()
282 .iter()
283 .filter(|n| !authenticated_note_ids.contains(&n.id()))
284 .cloned()
285 .map(Into::into)
286 .collect::<Vec<_>>();
287
288 self.store.upsert_input_notes(&unauthenticated_input_notes).await?;
289
290 let mut notes = transaction_request.build_input_notes(stored_note_records)?;
291
292 let output_recipients =
293 transaction_request.expected_output_recipients().cloned().collect::<Vec<_>>();
294
295 let future_notes: Vec<(NoteDetails, NoteTag)> =
296 transaction_request.expected_future_notes().cloned().collect();
297
298 let tx_script = transaction_request.build_transaction_script(
299 &self.get_account_interface(account_id).await?,
300 self.source_manager.clone(),
301 )?;
302
303 let foreign_accounts = transaction_request.foreign_accounts().clone();
304
305 let (fpi_block_num, foreign_account_inputs) =
307 self.retrieve_foreign_account_inputs(foreign_accounts).await?;
308
309 let ignore_invalid_notes = transaction_request.ignore_invalid_input_notes();
310
311 let data_store = ClientDataStore::new(self.store.clone(), self.rpc_api.clone());
312 data_store.register_foreign_account_inputs(foreign_account_inputs.iter().cloned());
313 for fpi_account in &foreign_account_inputs {
314 data_store.mast_store().load_account_code(fpi_account.code());
315 }
316
317 let output_note_scripts: Vec<NoteScript> = transaction_request
319 .expected_output_recipients()
320 .map(|n| n.script().clone())
321 .collect();
322 self.store.upsert_note_scripts(&output_note_scripts).await?;
323
324 let block_num = if let Some(block_num) = fpi_block_num {
325 block_num
326 } else {
327 self.store.get_sync_height().await?
328 };
329
330 let account_record = self
333 .store
334 .get_account(account_id)
335 .await?
336 .ok_or(ClientError::AccountDataNotFound(account_id))?;
337 let account: Account = account_record.try_into()?;
338 data_store.mast_store().load_account_code(account.code());
339
340 let tx_args = transaction_request.into_transaction_args(tx_script);
342
343 if ignore_invalid_notes {
344 notes = self.get_valid_input_notes(account, notes, tx_args.clone()).await?;
346 }
347
348 let executed_transaction = self
350 .build_executor(&data_store)?
351 .execute_transaction(account_id, block_num, notes, tx_args)
352 .await?;
353
354 validate_executed_transaction(&executed_transaction, &output_recipients)?;
355 TransactionResult::new(executed_transaction, future_notes)
356 }
357
358 pub async fn prove_transaction(
360 &mut self,
361 tx_result: &TransactionResult,
362 ) -> Result<ProvenTransaction, ClientError> {
363 self.prove_transaction_with(tx_result, self.tx_prover.clone()).await
364 }
365
366 pub async fn prove_transaction_with(
368 &mut self,
369 tx_result: &TransactionResult,
370 tx_prover: Arc<dyn TransactionProver>,
371 ) -> Result<ProvenTransaction, ClientError> {
372 info!("Proving transaction...");
373
374 let proven_transaction =
375 tx_prover.prove(tx_result.executed_transaction().clone().into()).await?;
376
377 info!("Transaction proven.");
378
379 Ok(proven_transaction)
380 }
381
382 pub async fn submit_proven_transaction(
385 &mut self,
386 proven_transaction: ProvenTransaction,
387 transaction_inputs: impl Into<TransactionInputs>,
388 ) -> Result<BlockNumber, ClientError> {
389 info!("Submitting transaction to the network...");
390 let block_num = self
391 .rpc_api
392 .submit_proven_transaction(proven_transaction, transaction_inputs.into())
393 .await?;
394 info!("Transaction submitted.");
395
396 Ok(block_num)
397 }
398
399 pub async fn get_transaction_store_update(
402 &self,
403 tx_result: &TransactionResult,
404 submission_height: BlockNumber,
405 ) -> Result<TransactionStoreUpdate, ClientError> {
406 let note_updates = self.get_note_updates(submission_height, tx_result).await?;
407
408 let mut new_tags: Vec<NoteTagRecord> = note_updates
409 .updated_input_notes()
410 .filter_map(|note| {
411 let note = note.inner();
412
413 if let InputNoteState::Expected(ExpectedNoteState { tag: Some(tag), .. }) =
414 note.state()
415 {
416 Some(NoteTagRecord::with_note_source(*tag, note.id()))
417 } else {
418 None
419 }
420 })
421 .collect();
422
423 new_tags.extend(note_updates.updated_output_notes().map(|note| {
425 let note = note.inner();
426 NoteTagRecord::with_note_source(note.metadata().tag(), note.id())
427 }));
428
429 Ok(TransactionStoreUpdate::new(
430 tx_result.executed_transaction().clone(),
431 submission_height,
432 note_updates,
433 tx_result.future_notes().to_vec(),
434 new_tags,
435 ))
436 }
437
438 pub async fn apply_transaction(
441 &self,
442 tx_result: &TransactionResult,
443 submission_height: BlockNumber,
444 ) -> Result<(), ClientError> {
445 let tx_update = self.get_transaction_store_update(tx_result, submission_height).await?;
446
447 self.apply_transaction_update(tx_update).await
448 }
449
450 pub async fn apply_transaction_update(
451 &self,
452 tx_update: TransactionStoreUpdate,
453 ) -> Result<(), ClientError> {
454 info!("Applying transaction to the local store...");
457
458 let executed_transaction = tx_update.executed_transaction();
459 let account_id = executed_transaction.account_id();
460
461 if self.account_reader(account_id).status().await?.is_locked() {
462 return Err(ClientError::AccountLocked(account_id));
463 }
464
465 self.store.apply_transaction(tx_update).await?;
466 info!("Transaction stored.");
467 Ok(())
468 }
469
470 pub async fn execute_program(
475 &mut self,
476 account_id: AccountId,
477 tx_script: TransactionScript,
478 advice_inputs: AdviceInputs,
479 foreign_accounts: BTreeMap<AccountId, ForeignAccount>,
480 ) -> Result<[Felt; 16], ClientError> {
481 let (fpi_block_number, foreign_account_inputs) =
482 self.retrieve_foreign_account_inputs(foreign_accounts).await?;
483
484 let block_ref = if let Some(block_number) = fpi_block_number {
485 block_number
486 } else {
487 self.get_sync_height().await?
488 };
489
490 let account_record = self
491 .store
492 .get_account(account_id)
493 .await?
494 .ok_or(ClientError::AccountDataNotFound(account_id))?;
495
496 let account: Account = account_record.try_into()?;
497
498 let data_store = ClientDataStore::new(self.store.clone(), self.rpc_api.clone());
499
500 data_store.register_foreign_account_inputs(foreign_account_inputs.iter().cloned());
501
502 data_store.mast_store().load_account_code(account.code());
504
505 for fpi_account in &foreign_account_inputs {
506 data_store.mast_store().load_account_code(fpi_account.code());
507 }
508
509 Ok(self
510 .build_executor(&data_store)?
511 .execute_tx_view_script(account_id, block_ref, tx_script, advice_inputs)
512 .await?)
513 }
514
515 async fn get_note_updates(
528 &self,
529 submission_height: BlockNumber,
530 tx_result: &TransactionResult,
531 ) -> Result<NoteUpdateTracker, ClientError> {
532 let executed_tx = tx_result.executed_transaction();
533 let current_timestamp = self.store.get_current_timestamp();
534 let current_block_num = self.store.get_sync_height().await?;
535
536 let new_output_notes = executed_tx
538 .output_notes()
539 .iter()
540 .cloned()
541 .filter_map(|output_note| {
542 OutputNoteRecord::try_from_output_note(output_note, submission_height).ok()
543 })
544 .collect::<Vec<_>>();
545
546 let mut new_input_notes = vec![];
548 let output_notes =
549 notes_from_output(executed_tx.output_notes()).cloned().collect::<Vec<_>>();
550 let note_screener = self.note_screener();
551 let output_note_relevances = note_screener.can_consume_batch(&output_notes).await?;
552
553 for note in output_notes {
554 if output_note_relevances.contains_key(¬e.id()) {
555 let metadata = note.metadata().clone();
556 let tag = metadata.tag();
557
558 new_input_notes.push(InputNoteRecord::new(
559 note.into(),
560 current_timestamp,
561 ExpectedNoteState {
562 metadata: Some(metadata),
563 after_block_num: submission_height,
564 tag: Some(tag),
565 }
566 .into(),
567 ));
568 }
569 }
570
571 new_input_notes.extend(tx_result.future_notes().iter().map(|(note_details, tag)| {
573 InputNoteRecord::new(
574 note_details.clone(),
575 None,
576 ExpectedNoteState {
577 metadata: None,
578 after_block_num: current_block_num,
579 tag: Some(*tag),
580 }
581 .into(),
582 )
583 }));
584
585 let consumed_note_ids =
587 executed_tx.tx_inputs().input_notes().iter().map(InputNote::id).collect();
588
589 let consumed_notes = self.get_input_notes(NoteFilter::List(consumed_note_ids)).await?;
590
591 let mut updated_input_notes = vec![];
592
593 for mut input_note_record in consumed_notes {
594 if input_note_record.consumed_locally(
595 executed_tx.account_id(),
596 executed_tx.id(),
597 self.store.get_current_timestamp(),
598 )? {
599 updated_input_notes.push(input_note_record);
600 }
601 }
602
603 Ok(NoteUpdateTracker::for_transaction_updates(
604 new_input_notes,
605 updated_input_notes,
606 new_output_notes,
607 ))
608 }
609
610 pub async fn validate_request(
617 &mut self,
618 account_id: AccountId,
619 transaction_request: &TransactionRequest,
620 ) -> Result<(), ClientError> {
621 if let Some(max_block_number_delta) = self.max_block_number_delta {
622 let current_chain_tip =
623 self.rpc_api.get_block_header_by_number(None, false).await?.0.block_num();
624
625 if current_chain_tip > self.store.get_sync_height().await? + max_block_number_delta {
626 return Err(ClientError::RecencyConditionError(
627 "The client is too far behind the chain tip to execute the transaction",
628 ));
629 }
630 }
631
632 let account = self.try_get_account(account_id).await?;
633 if account.is_faucet() {
634 Ok(())
636 } else {
637 validate_basic_account_request(transaction_request, &account)
638 }
639 }
640
641 pub async fn ensure_ntx_scripts_registered(
651 &mut self,
652 account_id: AccountId,
653 scripts: &[NoteScript],
654 tx_prover: Arc<dyn TransactionProver>,
655 ) -> Result<(), ClientError> {
656 let mut missing_scripts = Vec::new();
657
658 for script in scripts {
659 let script_root = script.root();
660
661 match self.rpc_api.get_note_script_by_root(script_root).await {
663 Ok(_) => {},
664 Err(RpcError::RequestError { error_kind: GrpcError::NotFound, .. }) => {
665 missing_scripts.push(script.clone());
666 },
667 Err(other) => {
668 return Err(ClientError::NtxScriptRegistrationFailed {
669 script_root,
670 source: other,
671 });
672 },
673 }
674 }
675
676 if missing_scripts.is_empty() {
677 return Ok(());
678 }
679
680 let registration_request = TransactionRequestBuilder::new().build_register_note_scripts(
681 account_id,
682 missing_scripts,
683 self.rng(),
684 )?;
685
686 let tx_result = self.execute_transaction(account_id, registration_request).await?;
687 let proven = self.prove_transaction_with(&tx_result, tx_prover).await?;
688 let submission_height = self.submit_proven_transaction(proven, &tx_result).await?;
689 self.apply_transaction(&tx_result, submission_height).await?;
690
691 Ok(())
692 }
693
694 async fn get_valid_input_notes(
697 &self,
698 account: Account,
699 mut input_notes: InputNotes<InputNote>,
700 tx_args: TransactionArgs,
701 ) -> Result<InputNotes<InputNote>, ClientError> {
702 loop {
703 let data_store = ClientDataStore::new(self.store.clone(), self.rpc_api.clone());
704
705 data_store.mast_store().load_account_code(account.code());
706 let execution = NoteConsumptionChecker::new(&self.build_executor(&data_store)?)
707 .check_notes_consumability(
708 account.id(),
709 self.store.get_sync_height().await?,
710 input_notes.iter().map(|n| n.clone().into_note()).collect(),
711 tx_args.clone(),
712 )
713 .await?;
714
715 if execution.failed.is_empty() {
716 break;
717 }
718
719 let failed_note_ids: BTreeSet<NoteId> =
720 execution.failed.iter().map(|n| n.note.id()).collect();
721 let filtered_input_notes = InputNotes::new(
722 input_notes
723 .into_iter()
724 .filter(|note| !failed_note_ids.contains(¬e.id()))
725 .collect(),
726 )
727 .expect("Created from a valid input notes list");
728
729 input_notes = filtered_input_notes;
730 }
731
732 Ok(input_notes)
733 }
734
735 pub(crate) async fn get_account_interface(
737 &self,
738 account_id: AccountId,
739 ) -> Result<AccountInterface, ClientError> {
740 let account = self.try_get_account(account_id).await?;
741 Ok(AccountInterface::from_account(&account))
742 }
743
744 async fn retrieve_foreign_account_inputs(
755 &mut self,
756 foreign_accounts: BTreeMap<AccountId, ForeignAccount>,
757 ) -> Result<(Option<BlockNumber>, Vec<AccountInputs>), ClientError> {
758 if foreign_accounts.is_empty() {
759 return Ok((None, Vec::new()));
760 }
761
762 let block_num = self.get_sync_height().await?;
763 let mut return_foreign_account_inputs = Vec::with_capacity(foreign_accounts.len());
764
765 for foreign_account in foreign_accounts.into_values() {
766 let foreign_account_inputs = match foreign_account {
767 ForeignAccount::Public(account_id, storage_requirements) => {
768 fetch_public_account_inputs(
769 &self.store,
770 &self.rpc_api,
771 account_id,
772 storage_requirements,
773 AccountStateAt::Block(block_num),
774 )
775 .await?
776 },
777 ForeignAccount::Private(partial_account) => {
778 let account_id = partial_account.id();
779 let (_, account_proof) = self
780 .rpc_api
781 .get_account_proof(
782 account_id,
783 AccountStorageRequirements::default(),
784 AccountStateAt::Block(block_num),
785 None,
786 None,
787 )
788 .await?;
789 let (witness, _) = account_proof.into_parts();
790 AccountInputs::new(partial_account, witness)
791 },
792 };
793
794 return_foreign_account_inputs.push(foreign_account_inputs);
795 }
796
797 Ok((Some(block_num), return_foreign_account_inputs))
798 }
799
800 pub(crate) fn build_executor<'store, 'auth, STORE: DataStore + Sync>(
803 &'auth self,
804 data_store: &'store STORE,
805 ) -> Result<TransactionExecutor<'store, 'auth, STORE, AUTH>, TransactionExecutorError> {
806 let mut executor = TransactionExecutor::new(data_store).with_options(self.exec_options)?;
807 if let Some(authenticator) = self.authenticator.as_deref() {
808 executor = executor.with_authenticator(authenticator);
809 }
810 executor = executor.with_source_manager(self.source_manager.clone());
811
812 Ok(executor)
813 }
814}
815
816fn get_outgoing_assets(
824 transaction_request: &TransactionRequest,
825) -> (BTreeMap<AccountId, u64>, Vec<NonFungibleAsset>) {
826 let mut own_notes_assets = match transaction_request.script_template() {
828 Some(TransactionScriptTemplate::SendNotes(notes)) => notes
829 .iter()
830 .map(|note| (note.id(), note.assets().clone()))
831 .collect::<BTreeMap<_, _>>(),
832 _ => BTreeMap::default(),
833 };
834 let mut output_notes_assets = transaction_request
836 .expected_output_own_notes()
837 .into_iter()
838 .map(|note| (note.id(), note.assets().clone()))
839 .collect::<BTreeMap<_, _>>();
840
841 output_notes_assets.append(&mut own_notes_assets);
843
844 let outgoing_assets = output_notes_assets.values().flat_map(|note_assets| note_assets.iter());
846
847 request::collect_assets(outgoing_assets)
848}
849
850fn validate_basic_account_request(
853 transaction_request: &TransactionRequest,
854 account: &Account,
855) -> Result<(), ClientError> {
856 let (fungible_balance_map, non_fungible_set) = get_outgoing_assets(transaction_request);
858
859 let (incoming_fungible_balance_map, incoming_non_fungible_balance_set) =
861 transaction_request.incoming_assets();
862
863 for (faucet_id, amount) in fungible_balance_map {
866 let account_asset_amount = account.vault().get_balance(faucet_id).unwrap_or(0);
867 let incoming_balance = incoming_fungible_balance_map.get(&faucet_id).unwrap_or(&0);
868 if account_asset_amount + incoming_balance < amount {
869 return Err(ClientError::AssetError(AssetError::FungibleAssetAmountNotSufficient {
870 minuend: account_asset_amount,
871 subtrahend: amount,
872 }));
873 }
874 }
875
876 for non_fungible in &non_fungible_set {
879 match account.vault().has_non_fungible_asset(*non_fungible) {
880 Ok(true) => (),
881 Ok(false) => {
882 if !incoming_non_fungible_balance_set.contains(non_fungible) {
884 return Err(ClientError::AssetError(
885 AssetError::NonFungibleFaucetIdTypeMismatch(non_fungible.faucet_id()),
886 ));
887 }
888 },
889 _ => {
890 return Err(ClientError::AssetError(AssetError::NonFungibleFaucetIdTypeMismatch(
891 non_fungible.faucet_id(),
892 )));
893 },
894 }
895 }
896
897 Ok(())
898}
899
900pub(crate) async fn fetch_public_account_inputs(
907 store: &Arc<dyn Store>,
908 rpc_api: &Arc<dyn NodeRpcClient>,
909 account_id: AccountId,
910 storage_requirements: AccountStorageRequirements,
911 account_state_at: AccountStateAt,
912) -> Result<AccountInputs, ClientError> {
913 let known_account_code: Option<AccountCode> =
914 store.get_foreign_account_code(vec![account_id]).await?.into_values().next();
915
916 let (_, account_proof) = rpc_api
917 .get_account_proof(
918 account_id,
919 storage_requirements.clone(),
920 account_state_at,
921 known_account_code,
922 Some(EMPTY_WORD),
923 )
924 .await?;
925
926 let account_inputs = request::account_proof_into_inputs(account_proof, &storage_requirements)?;
927
928 let _ = store
929 .upsert_foreign_account_code(account_id, account_inputs.code().clone())
930 .await
931 .inspect_err(|err| {
932 tracing::warn!(
933 %account_id,
934 %err,
935 "Failed to persist foreign account code to store"
936 );
937 });
938
939 Ok(account_inputs)
940}
941
942pub fn notes_from_output(output_notes: &RawOutputNotes) -> impl Iterator<Item = &Note> {
947 output_notes.iter().filter_map(|n| match n {
948 RawOutputNote::Full(n) => Some(n),
949 RawOutputNote::Partial(_) => None,
950 })
951}
952
953fn validate_executed_transaction(
956 executed_transaction: &ExecutedTransaction,
957 expected_output_recipients: &[NoteRecipient],
958) -> Result<(), ClientError> {
959 let tx_output_recipient_digests = executed_transaction
960 .output_notes()
961 .iter()
962 .filter_map(|n| n.recipient().map(NoteRecipient::digest))
963 .collect::<Vec<_>>();
964
965 let missing_recipient_digest: Vec<Word> = expected_output_recipients
966 .iter()
967 .filter_map(|recipient| {
968 (!tx_output_recipient_digests.contains(&recipient.digest()))
969 .then_some(recipient.digest())
970 })
971 .collect();
972
973 if !missing_recipient_digest.is_empty() {
974 return Err(ClientError::MissingOutputRecipients(missing_recipient_digest));
975 }
976
977 Ok(())
978}