1use alloc::boxed::Box;
4use alloc::collections::{BTreeMap, BTreeSet};
5use alloc::string::{String, ToString};
6use alloc::vec::Vec;
7use core::num::NonZeroU16;
8
9use miden_protocol::Word;
10use miden_protocol::account::{AccountCodeInterface, AccountId};
11use miden_protocol::asset::{Asset, NonFungibleAsset};
12use miden_protocol::crypto::merkle::MerkleError;
13use miden_protocol::crypto::merkle::store::MerkleStore;
14use miden_protocol::errors::{
15 AccountError,
16 AssetError,
17 AssetVaultError,
18 NoteError,
19 StorageMapError,
20 TransactionInputError,
21 TransactionScriptError,
22};
23use miden_protocol::note::{
24 Note,
25 NoteDetails,
26 NoteDetailsCommitment,
27 NoteId,
28 NoteRecipient,
29 NoteScript,
30 NoteTag,
31 PartialNote,
32};
33use miden_protocol::transaction::{InputNote, InputNotes, TransactionArgs, TransactionScript};
34use miden_protocol::vm::AdviceMap;
35use miden_standards::account::auth::{FeeConversionInfo, commit_fee_conversion_info};
36use miden_standards::errors::CodeBuilderError;
37use miden_standards::tx_script::{SendNotesTransactionScript, SendNotesTransactionScriptError};
38use miden_tx::utils::serde::{
39 ByteReader,
40 ByteWriter,
41 Deserializable,
42 DeserializationError,
43 Serializable,
44};
45use thiserror::Error;
46
47mod builder;
48pub use builder::{
49 PaymentNoteDescription,
50 PswapTransactionData,
51 SwapTransactionData,
52 TransactionRequestBuilder,
53};
54
55mod foreign;
56pub(crate) use foreign::account_proof_into_inputs;
57pub use foreign::{ForeignAccount, build_fpi_script};
58
59use crate::store::InputNoteRecord;
60
61pub type NoteArgs = Word;
65
66#[derive(Clone, Debug, PartialEq, Eq)]
71pub enum TransactionScriptTemplate {
72 CustomScript(TransactionScript),
74 SendNotes(Vec<PartialNote>),
80}
81
82#[derive(Clone, Debug, PartialEq, Eq)]
88pub struct TransactionRequest {
89 input_notes: Vec<Note>,
94 input_notes_args: Vec<(NoteId, Option<NoteArgs>)>,
97 script_template: Option<TransactionScriptTemplate>,
99 expected_output_recipients: BTreeMap<Word, NoteRecipient>,
101 expected_future_notes: BTreeMap<NoteDetailsCommitment, (NoteDetails, NoteTag)>,
106 advice_map: AdviceMap,
108 merkle_store: MerkleStore,
110 foreign_accounts: BTreeMap<AccountId, ForeignAccount>,
114 expiration_delta: Option<u16>,
117 ignore_invalid_input_notes: bool,
121 script_arg: Option<Word>,
124 auth_arg: Option<Word>,
127 fee_conversion_salt: Option<Word>,
131 expected_ntx_scripts: Vec<NoteScript>,
135}
136
137impl TransactionRequest {
138 pub fn input_notes(&self) -> &[Note] {
143 &self.input_notes
144 }
145
146 pub fn input_note_ids(&self) -> impl Iterator<Item = NoteId> {
148 self.input_notes.iter().map(Note::id)
149 }
150
151 pub fn incoming_assets(&self) -> (BTreeMap<AccountId, u64>, Vec<NonFungibleAsset>) {
153 collect_assets(self.input_notes.iter().flat_map(|note| note.assets().iter()))
154 }
155
156 pub fn get_note_args(&self) -> BTreeMap<NoteId, NoteArgs> {
159 self.input_notes_args
160 .iter()
161 .filter_map(|(note, args)| args.map(|a| (*note, a)))
162 .collect()
163 }
164
165 pub fn expected_output_own_notes(&self) -> Vec<Note> {
171 match &self.script_template {
172 Some(TransactionScriptTemplate::SendNotes(notes)) => notes
173 .iter()
174 .map(|partial| {
175 Note::with_attachments(
176 partial.assets().clone(),
177 *partial.partial_metadata(),
178 self.expected_output_recipients
179 .get(&partial.recipient_digest())
180 .expect("Recipient should be included if it's an own note")
181 .clone(),
182 partial.attachments().clone(),
183 )
184 })
185 .collect(),
186 _ => vec![],
187 }
188 }
189
190 pub fn expected_output_recipients(&self) -> impl Iterator<Item = &NoteRecipient> {
192 self.expected_output_recipients.values()
193 }
194
195 pub fn expected_future_notes(&self) -> impl Iterator<Item = &(NoteDetails, NoteTag)> {
197 self.expected_future_notes.values()
198 }
199
200 pub fn script_template(&self) -> &Option<TransactionScriptTemplate> {
202 &self.script_template
203 }
204
205 pub fn advice_map(&self) -> &AdviceMap {
207 &self.advice_map
208 }
209
210 pub fn advice_map_mut(&mut self) -> &mut AdviceMap {
212 &mut self.advice_map
213 }
214
215 pub fn merkle_store(&self) -> &MerkleStore {
217 &self.merkle_store
218 }
219
220 pub fn foreign_accounts(&self) -> &BTreeMap<AccountId, ForeignAccount> {
222 &self.foreign_accounts
223 }
224
225 pub fn ignore_invalid_input_notes(&self) -> bool {
227 self.ignore_invalid_input_notes
228 }
229
230 pub fn script_arg(&self) -> &Option<Word> {
232 &self.script_arg
233 }
234
235 pub fn auth_arg(&self) -> &Option<Word> {
237 &self.auth_arg
238 }
239
240 pub fn fee_conversion_salt(&self) -> Option<Word> {
244 self.fee_conversion_salt
245 }
246
247 pub fn has_auth_arg(&self) -> bool {
252 self.auth_arg.is_some_and(|auth_arg| auth_arg != Word::empty())
253 }
254
255 pub fn expected_ntx_scripts(&self) -> &[NoteScript] {
257 &self.expected_ntx_scripts
258 }
259
260 #[cfg(feature = "testing")]
268 pub(crate) fn add_unauthenticated_input_note(&mut self, note: Note) {
269 self.input_notes_args.push((note.id(), None));
270 self.input_notes.push(note);
271 }
272
273 pub(crate) fn commit_native_fee_conversion_info(
276 &mut self,
277 fee_faucet_id: AccountId,
278 salt: Word,
279 ) {
280 let (auth_arg, preimage) =
281 commit_fee_conversion_info(FeeConversionInfo::one_to_one(fee_faucet_id), salt);
282 self.advice_map.insert(auth_arg, preimage);
283 self.auth_arg = Some(auth_arg);
284 }
285
286 pub(crate) fn build_input_notes(
293 &self,
294 authenticated_note_records: Vec<InputNoteRecord>,
295 ) -> Result<InputNotes<InputNote>, TransactionRequestError> {
296 let mut input_notes: BTreeMap<NoteId, InputNote> = BTreeMap::new();
297
298 for authenticated_note_record in authenticated_note_records {
300 let authenticated_note_id = authenticated_note_record
303 .id()
304 .expect("authenticated note record carries metadata so id() is Some");
305
306 if !authenticated_note_record.is_authenticated() {
307 return Err(TransactionRequestError::InputNoteNotAuthenticated(
308 authenticated_note_id,
309 ));
310 }
311
312 if authenticated_note_record.is_consumed() {
313 return Err(TransactionRequestError::InputNoteAlreadyConsumed(
314 authenticated_note_id,
315 ));
316 }
317
318 input_notes.insert(
319 authenticated_note_id,
320 authenticated_note_record
321 .try_into()
322 .expect("Authenticated note record should be convertible to InputNote"),
323 );
324 }
325
326 let authenticated_note_ids: BTreeSet<NoteId> = input_notes.keys().copied().collect();
328 for note in self.input_notes().iter().filter(|n| !authenticated_note_ids.contains(&n.id()))
329 {
330 input_notes.insert(note.id(), InputNote::Unauthenticated { note: note.clone() });
331 }
332
333 Ok(InputNotes::new(
334 self.input_note_ids()
335 .map(|note_id| {
336 input_notes
337 .remove(¬e_id)
338 .expect("The input note map was checked to contain all input notes")
339 })
340 .collect(),
341 )?)
342 }
343
344 pub(crate) fn into_transaction_args(
347 self,
348 tx_script: Option<(TransactionScript, Option<Word>)>,
349 ) -> TransactionArgs {
350 let note_args = self.get_note_args();
351 let TransactionRequest {
352 expected_output_recipients,
353 advice_map,
354 merkle_store,
355 ..
356 } = self;
357
358 let mut tx_args = TransactionArgs::new(advice_map).with_note_args(note_args);
359
360 if let Some((tx_script, script_args)) = tx_script {
364 let script_args = script_args.or(self.script_arg).unwrap_or_default();
365 tx_args = tx_args.with_tx_script_and_args(tx_script, script_args);
366 }
367
368 if let Some(auth_argument) = self.auth_arg {
369 tx_args = tx_args.with_auth_args(auth_argument);
370 }
371
372 tx_args
373 .extend_output_note_recipients(expected_output_recipients.into_values().map(Box::new));
374 tx_args.extend_merkle_store(merkle_store.inner_nodes());
375
376 tx_args
377 }
378
379 pub(crate) fn build_transaction_script(
393 &self,
394 code_interface: &AccountCodeInterface,
395 ) -> Result<Option<(TransactionScript, Option<Word>)>, TransactionRequestError> {
396 match &self.script_template {
397 Some(TransactionScriptTemplate::CustomScript(script)) => {
398 Ok(Some((script.clone(), None)))
399 },
400 Some(TransactionScriptTemplate::SendNotes(notes)) => {
401 let script = match self.expiration_delta.and_then(NonZeroU16::new) {
402 Some(delta) => SendNotesTransactionScript::with_expiration_delta(
403 code_interface,
404 notes,
405 delta,
406 )?,
407 None => SendNotesTransactionScript::new(code_interface, notes)?,
408 };
409 Ok(Some((script.tx_script().clone(), Some(script.tx_script_args()))))
410 },
411 None => Ok(None),
412 }
413 }
414}
415
416impl Serializable for TransactionRequest {
420 fn write_into<W: ByteWriter>(&self, target: &mut W) {
421 self.input_notes.write_into(target);
422 self.input_notes_args.write_into(target);
423 match &self.script_template {
424 None => target.write_u8(0),
425 Some(TransactionScriptTemplate::CustomScript(script)) => {
426 target.write_u8(1);
427 script.write_into(target);
428 },
429 Some(TransactionScriptTemplate::SendNotes(notes)) => {
430 target.write_u8(2);
431 notes.write_into(target);
432 },
433 }
434 self.expected_output_recipients.write_into(target);
435 self.expected_future_notes.write_into(target);
436 self.advice_map.write_into(target);
437 self.merkle_store.write_into(target);
438 let foreign_accounts: Vec<_> = self.foreign_accounts.values().cloned().collect();
439 foreign_accounts.write_into(target);
440 self.expiration_delta.write_into(target);
441 target.write_u8(u8::from(self.ignore_invalid_input_notes));
442 self.script_arg.write_into(target);
443 self.auth_arg.write_into(target);
444 self.fee_conversion_salt.write_into(target);
445 self.expected_ntx_scripts.write_into(target);
446 }
447}
448
449impl Deserializable for TransactionRequest {
450 fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
451 let input_notes = Vec::<Note>::read_from(source)?;
452 let input_notes_args = Vec::<(NoteId, Option<NoteArgs>)>::read_from(source)?;
453
454 let script_template = match source.read_u8()? {
455 0 => None,
456 1 => {
457 let transaction_script = TransactionScript::read_from(source)?;
458 Some(TransactionScriptTemplate::CustomScript(transaction_script))
459 },
460 2 => {
461 let notes = Vec::<PartialNote>::read_from(source)?;
462 Some(TransactionScriptTemplate::SendNotes(notes))
463 },
464 _ => {
465 return Err(DeserializationError::InvalidValue(
466 "Invalid script template type".to_string(),
467 ));
468 },
469 };
470
471 let expected_output_recipients = BTreeMap::<Word, NoteRecipient>::read_from(source)?;
472 let expected_future_notes =
473 BTreeMap::<NoteDetailsCommitment, (NoteDetails, NoteTag)>::read_from(source)?;
474
475 let advice_map = AdviceMap::read_from(source)?;
476 let merkle_store = MerkleStore::read_from(source)?;
477 let mut foreign_accounts = BTreeMap::new();
478 for foreign_account in Vec::<ForeignAccount>::read_from(source)? {
479 foreign_accounts.entry(foreign_account.account_id()).or_insert(foreign_account);
480 }
481 let expiration_delta = Option::<u16>::read_from(source)?;
482 let ignore_invalid_input_notes = source.read_u8()? == 1;
483 let script_arg = Option::<Word>::read_from(source)?;
484 let auth_arg = Option::<Word>::read_from(source)?;
485 let fee_conversion_salt = Option::<Word>::read_from(source)?;
486 let expected_ntx_scripts = Vec::<NoteScript>::read_from(source)?;
487
488 Ok(TransactionRequest {
489 input_notes,
490 input_notes_args,
491 script_template,
492 expected_output_recipients,
493 expected_future_notes,
494 advice_map,
495 merkle_store,
496 foreign_accounts,
497 expiration_delta,
498 ignore_invalid_input_notes,
499 script_arg,
500 auth_arg,
501 fee_conversion_salt,
502 expected_ntx_scripts,
503 })
504 }
505}
506
507pub(crate) fn collect_assets<'a>(
512 assets: impl Iterator<Item = &'a Asset>,
513) -> (BTreeMap<AccountId, u64>, Vec<NonFungibleAsset>) {
514 let mut fungible_balance_map = BTreeMap::new();
515 let mut non_fungible_set = Vec::new();
516
517 assets.for_each(|asset| match asset {
518 Asset::Fungible(fungible) => {
519 let amount = fungible.amount().as_u64();
520 fungible_balance_map
521 .entry(fungible.faucet_id())
522 .and_modify(|balance| *balance += amount)
523 .or_insert(amount);
524 },
525 Asset::NonFungible(non_fungible) => {
526 if !non_fungible_set.contains(non_fungible) {
527 non_fungible_set.push(*non_fungible);
528 }
529 },
530 });
531
532 (fungible_balance_map, non_fungible_set)
533}
534
535impl Default for TransactionRequestBuilder {
536 fn default() -> Self {
537 Self::new()
538 }
539}
540
541#[derive(Debug, Error)]
546pub enum TransactionRequestError {
547 #[error("failed to build the send-notes transaction script")]
548 SendNotesTransactionScriptError(#[from] SendNotesTransactionScriptError),
549 #[error("account error")]
550 AccountError(#[from] AccountError),
551 #[error("asset error")]
552 AssetError(#[from] AssetError),
553 #[error("duplicate input note: note {0} was added more than once to the transaction")]
554 DuplicateInputNote(NoteId),
555 #[error("transaction expiration delta must be greater than zero")]
556 ZeroExpirationDelta,
557 #[error(
558 "the account proof does not contain the required foreign account data; re-fetch the proof and retry"
559 )]
560 ForeignAccountDataMissing,
561 #[error(
562 "foreign account {0} has incompatible visibility; use `ForeignAccount::public()` for public accounts and `ForeignAccount::private()` for private accounts"
563 )]
564 InvalidForeignAccountId(AccountId),
565 #[error(
566 "note {0} cannot be used as an authenticated input: it does not have a valid inclusion proof"
567 )]
568 InputNoteNotAuthenticated(NoteId),
569 #[error("note {0} has already been consumed")]
570 InputNoteAlreadyConsumed(NoteId),
571 #[error(
572 "output note declares sender {actual} but the transaction is executed by account {expected}"
573 )]
574 OutputNoteSenderMismatch { expected: AccountId, actual: AccountId },
575 #[error(
576 "the request declares a fee conversion salt but the account's auth component {0} does not \
577 read the auth args as fee conversion info"
578 )]
579 FeeConversionInfoUnsupported(String),
580 #[error(
581 "account's `{0}` component reuses the fee conversion salt as a replay guard, so the \
582 caller must declare a fresh one with `TransactionRequestBuilder::fee_conversion_salt`"
583 )]
584 FeeConversionInfoRequired(String),
585 #[error("invalid transaction script")]
586 InvalidTransactionScript(#[from] TransactionScriptError),
587 #[error("merkle proof error")]
588 MerkleError(#[from] MerkleError),
589 #[error("empty transaction: the request has no input notes and no account state changes")]
590 NoInputNotesNorAccountChange,
591 #[error("note not found: {0}")]
592 NoteNotFound(String),
593 #[error("failed to create note")]
594 NoteCreationError(#[from] NoteError),
595 #[error("note failed validation")]
596 NoteValidationError(#[source] NoteError),
597 #[error("note execution failed")]
598 NoteExecutionError(#[source] NoteError),
599 #[error("failed to build note args")]
600 NoteArgError(#[source] NoteError),
601 #[error("pay-to-ID note must contain at least one asset to transfer")]
602 P2IDNoteWithoutAsset,
603 #[error(
604 "non-fungible asset issued by faucet {0} is not available in the account vault or incoming notes"
605 )]
606 MissingNonFungibleAsset(AccountId),
607 #[error("PSWAP note can only be cancelled by its creator: expected {expected}, got {actual}")]
608 PswapCancelCreatorMismatch { expected: AccountId, actual: AccountId },
609 #[error("error building script")]
610 CodeBuilderError(#[from] CodeBuilderError),
611 #[error("transaction script template error: {0}")]
612 ScriptTemplateError(String),
613 #[error("foreign procedure takes at most {max} input felts, got {actual}")]
614 ForeignProcedureInputsTooLong { max: usize, actual: usize },
615 #[error("storage slot {0} not found in account ID {1}")]
616 StorageSlotNotFound(u8, AccountId),
617 #[error("error while building the input notes")]
618 TransactionInputError(#[from] TransactionInputError),
619 #[error("account storage map error")]
620 StorageMapError(#[from] StorageMapError),
621 #[error("asset vault error")]
622 AssetVaultError(#[from] AssetVaultError),
623 #[error(
624 "unsupported authentication scheme ID {0}; supported schemes are: RpoFalcon512 (0) and EcdsaK256Keccak (1)"
625 )]
626 UnsupportedAuthSchemeId(u8),
627}
628
629#[cfg(test)]
633mod tests {
634 use std::vec::Vec;
635
636 use miden_protocol::account::auth::{AuthScheme, PublicKeyCommitment};
637 use miden_protocol::account::{
638 AccountBuilder,
639 AccountComponent,
640 AccountId,
641 AccountType,
642 StorageMapKey,
643 StorageSlotName,
644 };
645 use miden_protocol::asset::FungibleAsset;
646 use miden_protocol::crypto::rand::{FeltRng, RandomCoin};
647 use miden_protocol::note::{NoteTag, NoteType};
648 use miden_protocol::testing::account_id::{
649 ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET,
650 ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
651 ACCOUNT_ID_SENDER,
652 };
653 use miden_protocol::{EMPTY_WORD, Felt, Word};
654 use miden_standards::account::auth::{Approver, AuthSingleSig};
655 use miden_standards::note::P2idNote;
656 use miden_standards::testing::account_component::MockAccountComponent;
657 use miden_tx::utils::serde::{Deserializable, Serializable};
658
659 use super::{TransactionRequest, TransactionRequestBuilder};
660 use crate::rpc::domain::account::AccountStorageRequirements;
661 use crate::transaction::ForeignAccount;
662
663 #[test]
664 fn transaction_request_serialization() {
665 assert_transaction_request_serialization_with(|| {
666 AuthSingleSig::new(Approver::new(
667 PublicKeyCommitment::from(EMPTY_WORD),
668 AuthScheme::Falcon512Poseidon2,
669 ))
670 .into()
671 });
672 }
673
674 #[test]
675 fn transaction_request_serialization_ecdsa() {
676 assert_transaction_request_serialization_with(|| {
677 AuthSingleSig::new(Approver::new(
678 PublicKeyCommitment::from(EMPTY_WORD),
679 AuthScheme::EcdsaK256Keccak,
680 ))
681 .into()
682 });
683 }
684
685 fn assert_transaction_request_serialization_with<F>(auth_component: F)
686 where
687 F: FnOnce() -> AccountComponent,
688 {
689 let sender_id = AccountId::try_from(ACCOUNT_ID_SENDER).unwrap();
690 let target_id =
691 AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap();
692 let faucet_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET).unwrap();
693 let mut rng = RandomCoin::new(Word::default());
694
695 let mut notes = vec![];
696 for i in 0..6 {
697 let note = P2idNote::builder()
698 .sender(sender_id)
699 .target(target_id)
700 .assets(vec![FungibleAsset::new(faucet_id, 100 + i).unwrap()])
701 .note_type(NoteType::Private)
702 .generate_serial_number(&mut rng)
703 .build()
704 .expect("note creation failed");
705 notes.push(note.into());
706 }
707
708 let mut advice_vec: Vec<(Word, Vec<Felt>)> = vec![];
709 for i in 0u32..10 {
710 advice_vec.push((rng.draw_word(), vec![Felt::from(i)]));
711 }
712
713 let account = AccountBuilder::new(Default::default())
714 .with_component(MockAccountComponent::with_empty_slots())
715 .with_component(auth_component())
716 .account_type(AccountType::Private)
717 .build_existing()
718 .unwrap();
719
720 let tx_request = TransactionRequestBuilder::new()
722 .input_notes(vec![(notes.pop().unwrap(), None)])
723 .expected_output_recipients(vec![notes.pop().unwrap().recipient().clone()])
724 .expected_future_notes(vec![(
725 notes.pop().unwrap().into(),
726 NoteTag::with_account_target(sender_id),
727 )])
728 .extend_advice_map(advice_vec)
729 .foreign_accounts([
730 ForeignAccount::public(
731 target_id,
732 AccountStorageRequirements::new([(
733 StorageSlotName::new("demo::storage_slot").unwrap(),
734 &[StorageMapKey::new(Word::default())],
735 )]),
736 )
737 .unwrap(),
738 ForeignAccount::private(&account).unwrap(),
739 ])
740 .own_output_notes(vec![notes.pop().unwrap(), notes.pop().unwrap()])
741 .script_arg(rng.draw_word())
742 .auth_arg(rng.draw_word())
743 .expected_ntx_scripts(vec![notes.first().unwrap().recipient().script().clone()])
744 .build()
745 .unwrap();
746
747 let mut buffer = Vec::new();
748 tx_request.write_into(&mut buffer);
749
750 let deserialized_tx_request = TransactionRequest::read_from_bytes(&buffer).unwrap();
751 assert_eq!(tx_request, deserialized_tx_request);
752 }
753}