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::errors::CodeBuilderError;
36use miden_standards::tx_script::{SendNotesTransactionScript, SendNotesTransactionScriptError};
37use miden_tx::utils::serde::{
38 ByteReader,
39 ByteWriter,
40 Deserializable,
41 DeserializationError,
42 Serializable,
43};
44use thiserror::Error;
45
46mod builder;
47pub use builder::{
48 PaymentNoteDescription,
49 PswapTransactionData,
50 SwapTransactionData,
51 TransactionRequestBuilder,
52};
53
54mod foreign;
55pub(crate) use foreign::account_proof_into_inputs;
56pub use foreign::{ForeignAccount, build_fpi_script};
57
58use crate::store::InputNoteRecord;
59
60pub type NoteArgs = Word;
64
65#[derive(Clone, Debug, PartialEq, Eq)]
70pub enum TransactionScriptTemplate {
71 CustomScript(TransactionScript),
73 SendNotes(Vec<PartialNote>),
79}
80
81#[derive(Clone, Debug, PartialEq, Eq)]
87pub struct TransactionRequest {
88 input_notes: Vec<Note>,
93 input_notes_args: Vec<(NoteId, Option<NoteArgs>)>,
96 script_template: Option<TransactionScriptTemplate>,
98 expected_output_recipients: BTreeMap<Word, NoteRecipient>,
100 expected_future_notes: BTreeMap<NoteDetailsCommitment, (NoteDetails, NoteTag)>,
105 advice_map: AdviceMap,
107 merkle_store: MerkleStore,
109 foreign_accounts: BTreeMap<AccountId, ForeignAccount>,
113 expiration_delta: Option<u16>,
116 ignore_invalid_input_notes: bool,
120 script_arg: Option<Word>,
123 auth_arg: Option<Word>,
126 declares_fee_conversion_info: bool,
130 expected_ntx_scripts: Vec<NoteScript>,
134}
135
136impl TransactionRequest {
137 pub fn input_notes(&self) -> &[Note] {
142 &self.input_notes
143 }
144
145 pub fn input_note_ids(&self) -> impl Iterator<Item = NoteId> {
147 self.input_notes.iter().map(Note::id)
148 }
149
150 pub fn incoming_assets(&self) -> (BTreeMap<AccountId, u64>, Vec<NonFungibleAsset>) {
152 collect_assets(self.input_notes.iter().flat_map(|note| note.assets().iter()))
153 }
154
155 pub fn get_note_args(&self) -> BTreeMap<NoteId, NoteArgs> {
158 self.input_notes_args
159 .iter()
160 .filter_map(|(note, args)| args.map(|a| (*note, a)))
161 .collect()
162 }
163
164 pub fn expected_output_own_notes(&self) -> Vec<Note> {
170 match &self.script_template {
171 Some(TransactionScriptTemplate::SendNotes(notes)) => notes
172 .iter()
173 .map(|partial| {
174 Note::with_attachments(
175 partial.assets().clone(),
176 *partial.partial_metadata(),
177 self.expected_output_recipients
178 .get(&partial.recipient_digest())
179 .expect("Recipient should be included if it's an own note")
180 .clone(),
181 partial.attachments().clone(),
182 )
183 })
184 .collect(),
185 _ => vec![],
186 }
187 }
188
189 pub fn expected_output_recipients(&self) -> impl Iterator<Item = &NoteRecipient> {
191 self.expected_output_recipients.values()
192 }
193
194 pub fn expected_future_notes(&self) -> impl Iterator<Item = &(NoteDetails, NoteTag)> {
196 self.expected_future_notes.values()
197 }
198
199 pub fn script_template(&self) -> &Option<TransactionScriptTemplate> {
201 &self.script_template
202 }
203
204 pub fn advice_map(&self) -> &AdviceMap {
206 &self.advice_map
207 }
208
209 pub fn advice_map_mut(&mut self) -> &mut AdviceMap {
211 &mut self.advice_map
212 }
213
214 pub fn merkle_store(&self) -> &MerkleStore {
216 &self.merkle_store
217 }
218
219 pub fn foreign_accounts(&self) -> &BTreeMap<AccountId, ForeignAccount> {
221 &self.foreign_accounts
222 }
223
224 pub fn ignore_invalid_input_notes(&self) -> bool {
226 self.ignore_invalid_input_notes
227 }
228
229 pub fn script_arg(&self) -> &Option<Word> {
231 &self.script_arg
232 }
233
234 pub fn auth_arg(&self) -> &Option<Word> {
236 &self.auth_arg
237 }
238
239 pub fn declares_fee_conversion_info(&self) -> bool {
242 self.declares_fee_conversion_info
243 }
244
245 pub fn expected_ntx_scripts(&self) -> &[NoteScript] {
247 &self.expected_ntx_scripts
248 }
249
250 pub(crate) fn build_input_notes(
257 &self,
258 authenticated_note_records: Vec<InputNoteRecord>,
259 ) -> Result<InputNotes<InputNote>, TransactionRequestError> {
260 let mut input_notes: BTreeMap<NoteId, InputNote> = BTreeMap::new();
261
262 for authenticated_note_record in authenticated_note_records {
264 let authenticated_note_id = authenticated_note_record
267 .id()
268 .expect("authenticated note record carries metadata so id() is Some");
269
270 if !authenticated_note_record.is_authenticated() {
271 return Err(TransactionRequestError::InputNoteNotAuthenticated(
272 authenticated_note_id,
273 ));
274 }
275
276 if authenticated_note_record.is_consumed() {
277 return Err(TransactionRequestError::InputNoteAlreadyConsumed(
278 authenticated_note_id,
279 ));
280 }
281
282 input_notes.insert(
283 authenticated_note_id,
284 authenticated_note_record
285 .try_into()
286 .expect("Authenticated note record should be convertible to InputNote"),
287 );
288 }
289
290 let authenticated_note_ids: BTreeSet<NoteId> = input_notes.keys().copied().collect();
292 for note in self.input_notes().iter().filter(|n| !authenticated_note_ids.contains(&n.id()))
293 {
294 input_notes.insert(note.id(), InputNote::Unauthenticated { note: note.clone() });
295 }
296
297 Ok(InputNotes::new(
298 self.input_note_ids()
299 .map(|note_id| {
300 input_notes
301 .remove(¬e_id)
302 .expect("The input note map was checked to contain all input notes")
303 })
304 .collect(),
305 )?)
306 }
307
308 pub(crate) fn into_transaction_args(
311 self,
312 tx_script: Option<(TransactionScript, Option<Word>)>,
313 ) -> TransactionArgs {
314 let note_args = self.get_note_args();
315 let TransactionRequest {
316 expected_output_recipients,
317 advice_map,
318 merkle_store,
319 ..
320 } = self;
321
322 let mut tx_args = TransactionArgs::new(advice_map).with_note_args(note_args);
323
324 if let Some((tx_script, script_args)) = tx_script {
328 let script_args = script_args.or(self.script_arg).unwrap_or_default();
329 tx_args = tx_args.with_tx_script_and_args(tx_script, script_args);
330 }
331
332 if let Some(auth_argument) = self.auth_arg {
333 tx_args = tx_args.with_auth_args(auth_argument);
334 }
335
336 tx_args
337 .extend_output_note_recipients(expected_output_recipients.into_values().map(Box::new));
338 tx_args.extend_merkle_store(merkle_store.inner_nodes());
339
340 tx_args
341 }
342
343 pub(crate) fn build_transaction_script(
357 &self,
358 code_interface: &AccountCodeInterface,
359 ) -> Result<Option<(TransactionScript, Option<Word>)>, TransactionRequestError> {
360 match &self.script_template {
361 Some(TransactionScriptTemplate::CustomScript(script)) => {
362 Ok(Some((script.clone(), None)))
363 },
364 Some(TransactionScriptTemplate::SendNotes(notes)) => {
365 let script = match self.expiration_delta.and_then(NonZeroU16::new) {
366 Some(delta) => SendNotesTransactionScript::with_expiration_delta(
367 code_interface,
368 notes,
369 delta,
370 )?,
371 None => SendNotesTransactionScript::new(code_interface, notes)?,
372 };
373 Ok(Some((script.tx_script().clone(), Some(script.tx_script_args()))))
374 },
375 None => Ok(None),
376 }
377 }
378}
379
380impl Serializable for TransactionRequest {
384 fn write_into<W: ByteWriter>(&self, target: &mut W) {
385 self.input_notes.write_into(target);
386 self.input_notes_args.write_into(target);
387 match &self.script_template {
388 None => target.write_u8(0),
389 Some(TransactionScriptTemplate::CustomScript(script)) => {
390 target.write_u8(1);
391 script.write_into(target);
392 },
393 Some(TransactionScriptTemplate::SendNotes(notes)) => {
394 target.write_u8(2);
395 notes.write_into(target);
396 },
397 }
398 self.expected_output_recipients.write_into(target);
399 self.expected_future_notes.write_into(target);
400 self.advice_map.write_into(target);
401 self.merkle_store.write_into(target);
402 let foreign_accounts: Vec<_> = self.foreign_accounts.values().cloned().collect();
403 foreign_accounts.write_into(target);
404 self.expiration_delta.write_into(target);
405 target.write_u8(u8::from(self.ignore_invalid_input_notes));
406 self.script_arg.write_into(target);
407 self.auth_arg.write_into(target);
408 target.write_u8(u8::from(self.declares_fee_conversion_info));
409 self.expected_ntx_scripts.write_into(target);
410 }
411}
412
413impl Deserializable for TransactionRequest {
414 fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
415 let input_notes = Vec::<Note>::read_from(source)?;
416 let input_notes_args = Vec::<(NoteId, Option<NoteArgs>)>::read_from(source)?;
417
418 let script_template = match source.read_u8()? {
419 0 => None,
420 1 => {
421 let transaction_script = TransactionScript::read_from(source)?;
422 Some(TransactionScriptTemplate::CustomScript(transaction_script))
423 },
424 2 => {
425 let notes = Vec::<PartialNote>::read_from(source)?;
426 Some(TransactionScriptTemplate::SendNotes(notes))
427 },
428 _ => {
429 return Err(DeserializationError::InvalidValue(
430 "Invalid script template type".to_string(),
431 ));
432 },
433 };
434
435 let expected_output_recipients = BTreeMap::<Word, NoteRecipient>::read_from(source)?;
436 let expected_future_notes =
437 BTreeMap::<NoteDetailsCommitment, (NoteDetails, NoteTag)>::read_from(source)?;
438
439 let advice_map = AdviceMap::read_from(source)?;
440 let merkle_store = MerkleStore::read_from(source)?;
441 let mut foreign_accounts = BTreeMap::new();
442 for foreign_account in Vec::<ForeignAccount>::read_from(source)? {
443 foreign_accounts.entry(foreign_account.account_id()).or_insert(foreign_account);
444 }
445 let expiration_delta = Option::<u16>::read_from(source)?;
446 let ignore_invalid_input_notes = source.read_u8()? == 1;
447 let script_arg = Option::<Word>::read_from(source)?;
448 let auth_arg = Option::<Word>::read_from(source)?;
449 let declares_fee_conversion_info = source.read_u8()? == 1;
450 let expected_ntx_scripts = Vec::<NoteScript>::read_from(source)?;
451
452 Ok(TransactionRequest {
453 input_notes,
454 input_notes_args,
455 script_template,
456 expected_output_recipients,
457 expected_future_notes,
458 advice_map,
459 merkle_store,
460 foreign_accounts,
461 expiration_delta,
462 ignore_invalid_input_notes,
463 script_arg,
464 auth_arg,
465 declares_fee_conversion_info,
466 expected_ntx_scripts,
467 })
468 }
469}
470
471pub(crate) fn collect_assets<'a>(
476 assets: impl Iterator<Item = &'a Asset>,
477) -> (BTreeMap<AccountId, u64>, Vec<NonFungibleAsset>) {
478 let mut fungible_balance_map = BTreeMap::new();
479 let mut non_fungible_set = Vec::new();
480
481 assets.for_each(|asset| match asset {
482 Asset::Fungible(fungible) => {
483 let amount = fungible.amount().as_u64();
484 fungible_balance_map
485 .entry(fungible.faucet_id())
486 .and_modify(|balance| *balance += amount)
487 .or_insert(amount);
488 },
489 Asset::NonFungible(non_fungible) => {
490 if !non_fungible_set.contains(non_fungible) {
491 non_fungible_set.push(*non_fungible);
492 }
493 },
494 });
495
496 (fungible_balance_map, non_fungible_set)
497}
498
499impl Default for TransactionRequestBuilder {
500 fn default() -> Self {
501 Self::new()
502 }
503}
504
505#[derive(Debug, Error)]
510pub enum TransactionRequestError {
511 #[error("failed to build the send-notes transaction script")]
512 SendNotesTransactionScriptError(#[from] SendNotesTransactionScriptError),
513 #[error("account error")]
514 AccountError(#[from] AccountError),
515 #[error("asset error")]
516 AssetError(#[from] AssetError),
517 #[error("duplicate input note: note {0} was added more than once to the transaction")]
518 DuplicateInputNote(NoteId),
519 #[error("transaction expiration delta must be greater than zero")]
520 ZeroExpirationDelta,
521 #[error(
522 "the account proof does not contain the required foreign account data; re-fetch the proof and retry"
523 )]
524 ForeignAccountDataMissing,
525 #[error(
526 "foreign account {0} has incompatible visibility; use `ForeignAccount::public()` for public accounts and `ForeignAccount::private()` for private accounts"
527 )]
528 InvalidForeignAccountId(AccountId),
529 #[error(
530 "note {0} cannot be used as an authenticated input: it does not have a valid inclusion proof"
531 )]
532 InputNoteNotAuthenticated(NoteId),
533 #[error("note {0} has already been consumed")]
534 InputNoteAlreadyConsumed(NoteId),
535 #[error(
536 "output note declares sender {actual} but the transaction is executed by account {expected}"
537 )]
538 OutputNoteSenderMismatch { expected: AccountId, actual: AccountId },
539 #[error(
540 "the request declares fee conversion info but the account's auth component {0} does not read it"
541 )]
542 FeeConversionInfoUnsupported(String),
543 #[error("invalid transaction script")]
544 InvalidTransactionScript(#[from] TransactionScriptError),
545 #[error("merkle proof error")]
546 MerkleError(#[from] MerkleError),
547 #[error("empty transaction: the request has no input notes and no account state changes")]
548 NoInputNotesNorAccountChange,
549 #[error("note not found: {0}")]
550 NoteNotFound(String),
551 #[error("failed to create note")]
552 NoteCreationError(#[from] NoteError),
553 #[error("note failed validation")]
554 NoteValidationError(#[source] NoteError),
555 #[error("note execution failed")]
556 NoteExecutionError(#[source] NoteError),
557 #[error("failed to build note args")]
558 NoteArgError(#[source] NoteError),
559 #[error("pay-to-ID note must contain at least one asset to transfer")]
560 P2IDNoteWithoutAsset,
561 #[error(
562 "non-fungible asset issued by faucet {0} is not available in the account vault or incoming notes"
563 )]
564 MissingNonFungibleAsset(AccountId),
565 #[error("PSWAP note can only be cancelled by its creator: expected {expected}, got {actual}")]
566 PswapCancelCreatorMismatch { expected: AccountId, actual: AccountId },
567 #[error("error building script")]
568 CodeBuilderError(#[from] CodeBuilderError),
569 #[error("transaction script template error: {0}")]
570 ScriptTemplateError(String),
571 #[error("foreign procedure takes at most {max} input felts, got {actual}")]
572 ForeignProcedureInputsTooLong { max: usize, actual: usize },
573 #[error("storage slot {0} not found in account ID {1}")]
574 StorageSlotNotFound(u8, AccountId),
575 #[error("error while building the input notes")]
576 TransactionInputError(#[from] TransactionInputError),
577 #[error("account storage map error")]
578 StorageMapError(#[from] StorageMapError),
579 #[error("asset vault error")]
580 AssetVaultError(#[from] AssetVaultError),
581 #[error(
582 "unsupported authentication scheme ID {0}; supported schemes are: RpoFalcon512 (0) and EcdsaK256Keccak (1)"
583 )]
584 UnsupportedAuthSchemeId(u8),
585}
586
587#[cfg(test)]
591mod tests {
592 use std::vec::Vec;
593
594 use miden_protocol::account::auth::{AuthScheme, PublicKeyCommitment};
595 use miden_protocol::account::{
596 AccountBuilder,
597 AccountComponent,
598 AccountId,
599 AccountType,
600 StorageMapKey,
601 StorageSlotName,
602 };
603 use miden_protocol::asset::FungibleAsset;
604 use miden_protocol::crypto::rand::{FeltRng, RandomCoin};
605 use miden_protocol::note::{NoteTag, NoteType};
606 use miden_protocol::testing::account_id::{
607 ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET,
608 ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
609 ACCOUNT_ID_SENDER,
610 };
611 use miden_protocol::{EMPTY_WORD, Felt, Word};
612 use miden_standards::account::auth::{Approver, AuthSingleSig};
613 use miden_standards::note::P2idNote;
614 use miden_standards::testing::account_component::MockAccountComponent;
615 use miden_tx::utils::serde::{Deserializable, Serializable};
616
617 use super::{TransactionRequest, TransactionRequestBuilder};
618 use crate::rpc::domain::account::AccountStorageRequirements;
619 use crate::transaction::ForeignAccount;
620
621 #[test]
622 fn transaction_request_serialization() {
623 assert_transaction_request_serialization_with(|| {
624 AuthSingleSig::new(Approver::new(
625 PublicKeyCommitment::from(EMPTY_WORD),
626 AuthScheme::Falcon512Poseidon2,
627 ))
628 .into()
629 });
630 }
631
632 #[test]
633 fn transaction_request_serialization_ecdsa() {
634 assert_transaction_request_serialization_with(|| {
635 AuthSingleSig::new(Approver::new(
636 PublicKeyCommitment::from(EMPTY_WORD),
637 AuthScheme::EcdsaK256Keccak,
638 ))
639 .into()
640 });
641 }
642
643 fn assert_transaction_request_serialization_with<F>(auth_component: F)
644 where
645 F: FnOnce() -> AccountComponent,
646 {
647 let sender_id = AccountId::try_from(ACCOUNT_ID_SENDER).unwrap();
648 let target_id =
649 AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap();
650 let faucet_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET).unwrap();
651 let mut rng = RandomCoin::new(Word::default());
652
653 let mut notes = vec![];
654 for i in 0..6 {
655 let note = P2idNote::builder()
656 .sender(sender_id)
657 .target(target_id)
658 .assets(vec![FungibleAsset::new(faucet_id, 100 + i).unwrap()])
659 .note_type(NoteType::Private)
660 .generate_serial_number(&mut rng)
661 .build()
662 .expect("note creation failed");
663 notes.push(note.into());
664 }
665
666 let mut advice_vec: Vec<(Word, Vec<Felt>)> = vec![];
667 for i in 0u32..10 {
668 advice_vec.push((rng.draw_word(), vec![Felt::from(i)]));
669 }
670
671 let account = AccountBuilder::new(Default::default())
672 .with_component(MockAccountComponent::with_empty_slots())
673 .with_component(auth_component())
674 .account_type(AccountType::Private)
675 .build_existing()
676 .unwrap();
677
678 let tx_request = TransactionRequestBuilder::new()
680 .input_notes(vec![(notes.pop().unwrap(), None)])
681 .expected_output_recipients(vec![notes.pop().unwrap().recipient().clone()])
682 .expected_future_notes(vec![(
683 notes.pop().unwrap().into(),
684 NoteTag::with_account_target(sender_id),
685 )])
686 .extend_advice_map(advice_vec)
687 .foreign_accounts([
688 ForeignAccount::public(
689 target_id,
690 AccountStorageRequirements::new([(
691 StorageSlotName::new("demo::storage_slot").unwrap(),
692 &[StorageMapKey::new(Word::default())],
693 )]),
694 )
695 .unwrap(),
696 ForeignAccount::private(&account).unwrap(),
697 ])
698 .own_output_notes(vec![notes.pop().unwrap(), notes.pop().unwrap()])
699 .script_arg(rng.draw_word())
700 .auth_arg(rng.draw_word())
701 .expected_ntx_scripts(vec![notes.first().unwrap().recipient().script().clone()])
702 .build()
703 .unwrap();
704
705 let mut buffer = Vec::new();
706 tx_request.write_into(&mut buffer);
707
708 let deserialized_tx_request = TransactionRequest::read_from_bytes(&buffer).unwrap();
709 assert_eq!(tx_request, deserialized_tx_request);
710 }
711}