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 use foreign::ForeignAccount;
56pub(crate) use foreign::account_proof_into_inputs;
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 expected_ntx_scripts: Vec<NoteScript>,
130}
131
132impl TransactionRequest {
133 pub fn input_notes(&self) -> &[Note] {
138 &self.input_notes
139 }
140
141 pub fn input_note_ids(&self) -> impl Iterator<Item = NoteId> {
143 self.input_notes.iter().map(Note::id)
144 }
145
146 pub fn incoming_assets(&self) -> (BTreeMap<AccountId, u64>, Vec<NonFungibleAsset>) {
148 collect_assets(self.input_notes.iter().flat_map(|note| note.assets().iter()))
149 }
150
151 pub fn get_note_args(&self) -> BTreeMap<NoteId, NoteArgs> {
154 self.input_notes_args
155 .iter()
156 .filter_map(|(note, args)| args.map(|a| (*note, a)))
157 .collect()
158 }
159
160 pub fn expected_output_own_notes(&self) -> Vec<Note> {
166 match &self.script_template {
167 Some(TransactionScriptTemplate::SendNotes(notes)) => notes
168 .iter()
169 .map(|partial| {
170 Note::with_attachments(
171 partial.assets().clone(),
172 *partial.partial_metadata(),
173 self.expected_output_recipients
174 .get(&partial.recipient_digest())
175 .expect("Recipient should be included if it's an own note")
176 .clone(),
177 partial.attachments().clone(),
178 )
179 })
180 .collect(),
181 _ => vec![],
182 }
183 }
184
185 pub fn expected_output_recipients(&self) -> impl Iterator<Item = &NoteRecipient> {
187 self.expected_output_recipients.values()
188 }
189
190 pub fn expected_future_notes(&self) -> impl Iterator<Item = &(NoteDetails, NoteTag)> {
192 self.expected_future_notes.values()
193 }
194
195 pub fn script_template(&self) -> &Option<TransactionScriptTemplate> {
197 &self.script_template
198 }
199
200 pub fn advice_map(&self) -> &AdviceMap {
202 &self.advice_map
203 }
204
205 pub fn advice_map_mut(&mut self) -> &mut AdviceMap {
207 &mut self.advice_map
208 }
209
210 pub fn merkle_store(&self) -> &MerkleStore {
212 &self.merkle_store
213 }
214
215 pub fn foreign_accounts(&self) -> &BTreeMap<AccountId, ForeignAccount> {
217 &self.foreign_accounts
218 }
219
220 pub fn ignore_invalid_input_notes(&self) -> bool {
222 self.ignore_invalid_input_notes
223 }
224
225 pub fn script_arg(&self) -> &Option<Word> {
227 &self.script_arg
228 }
229
230 pub fn auth_arg(&self) -> &Option<Word> {
232 &self.auth_arg
233 }
234
235 pub fn expected_ntx_scripts(&self) -> &[NoteScript] {
237 &self.expected_ntx_scripts
238 }
239
240 pub(crate) fn build_input_notes(
247 &self,
248 authenticated_note_records: Vec<InputNoteRecord>,
249 ) -> Result<InputNotes<InputNote>, TransactionRequestError> {
250 let mut input_notes: BTreeMap<NoteId, InputNote> = BTreeMap::new();
251
252 for authenticated_note_record in authenticated_note_records {
254 let authenticated_note_id = authenticated_note_record
257 .id()
258 .expect("authenticated note record carries metadata so id() is Some");
259
260 if !authenticated_note_record.is_authenticated() {
261 return Err(TransactionRequestError::InputNoteNotAuthenticated(
262 authenticated_note_id,
263 ));
264 }
265
266 if authenticated_note_record.is_consumed() {
267 return Err(TransactionRequestError::InputNoteAlreadyConsumed(
268 authenticated_note_id,
269 ));
270 }
271
272 input_notes.insert(
273 authenticated_note_id,
274 authenticated_note_record
275 .try_into()
276 .expect("Authenticated note record should be convertible to InputNote"),
277 );
278 }
279
280 let authenticated_note_ids: BTreeSet<NoteId> = input_notes.keys().copied().collect();
282 for note in self.input_notes().iter().filter(|n| !authenticated_note_ids.contains(&n.id()))
283 {
284 input_notes.insert(note.id(), InputNote::Unauthenticated { note: note.clone() });
285 }
286
287 Ok(InputNotes::new(
288 self.input_note_ids()
289 .map(|note_id| {
290 input_notes
291 .remove(¬e_id)
292 .expect("The input note map was checked to contain all input notes")
293 })
294 .collect(),
295 )?)
296 }
297
298 pub(crate) fn into_transaction_args(
301 self,
302 tx_script: Option<TransactionScript>,
303 ) -> TransactionArgs {
304 let note_args = self.get_note_args();
305 let TransactionRequest {
306 expected_output_recipients,
307 advice_map,
308 merkle_store,
309 ..
310 } = self;
311
312 let mut tx_args = TransactionArgs::new(advice_map).with_note_args(note_args);
313
314 if let Some(tx_script) = tx_script {
318 tx_args =
319 tx_args.with_tx_script_and_args(tx_script, self.script_arg.unwrap_or_default());
320 }
321
322 if let Some(auth_argument) = self.auth_arg {
323 tx_args = tx_args.with_auth_args(auth_argument);
324 }
325
326 tx_args
327 .extend_output_note_recipients(expected_output_recipients.into_values().map(Box::new));
328 tx_args.extend_merkle_store(merkle_store.inner_nodes());
329
330 tx_args
331 }
332
333 pub(crate) fn build_transaction_script(
342 &self,
343 code_interface: &AccountCodeInterface,
344 ) -> Result<Option<TransactionScript>, TransactionRequestError> {
345 match &self.script_template {
346 Some(TransactionScriptTemplate::CustomScript(script)) => Ok(Some(script.clone())),
347 Some(TransactionScriptTemplate::SendNotes(notes)) => {
348 let script = match self.expiration_delta.and_then(NonZeroU16::new) {
349 Some(delta) => SendNotesTransactionScript::with_expiration_delta(
350 code_interface,
351 notes,
352 delta,
353 )?,
354 None => SendNotesTransactionScript::new(code_interface, notes)?,
355 };
356 Ok(Some(script.into()))
357 },
358 None => Ok(None),
359 }
360 }
361}
362
363impl Serializable for TransactionRequest {
367 fn write_into<W: ByteWriter>(&self, target: &mut W) {
368 self.input_notes.write_into(target);
369 self.input_notes_args.write_into(target);
370 match &self.script_template {
371 None => target.write_u8(0),
372 Some(TransactionScriptTemplate::CustomScript(script)) => {
373 target.write_u8(1);
374 script.write_into(target);
375 },
376 Some(TransactionScriptTemplate::SendNotes(notes)) => {
377 target.write_u8(2);
378 notes.write_into(target);
379 },
380 }
381 self.expected_output_recipients.write_into(target);
382 self.expected_future_notes.write_into(target);
383 self.advice_map.write_into(target);
384 self.merkle_store.write_into(target);
385 let foreign_accounts: Vec<_> = self.foreign_accounts.values().cloned().collect();
386 foreign_accounts.write_into(target);
387 self.expiration_delta.write_into(target);
388 target.write_u8(u8::from(self.ignore_invalid_input_notes));
389 self.script_arg.write_into(target);
390 self.auth_arg.write_into(target);
391 self.expected_ntx_scripts.write_into(target);
392 }
393}
394
395impl Deserializable for TransactionRequest {
396 fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
397 let input_notes = Vec::<Note>::read_from(source)?;
398 let input_notes_args = Vec::<(NoteId, Option<NoteArgs>)>::read_from(source)?;
399
400 let script_template = match source.read_u8()? {
401 0 => None,
402 1 => {
403 let transaction_script = TransactionScript::read_from(source)?;
404 Some(TransactionScriptTemplate::CustomScript(transaction_script))
405 },
406 2 => {
407 let notes = Vec::<PartialNote>::read_from(source)?;
408 Some(TransactionScriptTemplate::SendNotes(notes))
409 },
410 _ => {
411 return Err(DeserializationError::InvalidValue(
412 "Invalid script template type".to_string(),
413 ));
414 },
415 };
416
417 let expected_output_recipients = BTreeMap::<Word, NoteRecipient>::read_from(source)?;
418 let expected_future_notes =
419 BTreeMap::<NoteDetailsCommitment, (NoteDetails, NoteTag)>::read_from(source)?;
420
421 let advice_map = AdviceMap::read_from(source)?;
422 let merkle_store = MerkleStore::read_from(source)?;
423 let mut foreign_accounts = BTreeMap::new();
424 for foreign_account in Vec::<ForeignAccount>::read_from(source)? {
425 foreign_accounts.entry(foreign_account.account_id()).or_insert(foreign_account);
426 }
427 let expiration_delta = Option::<u16>::read_from(source)?;
428 let ignore_invalid_input_notes = source.read_u8()? == 1;
429 let script_arg = Option::<Word>::read_from(source)?;
430 let auth_arg = Option::<Word>::read_from(source)?;
431 let expected_ntx_scripts = Vec::<NoteScript>::read_from(source)?;
432
433 Ok(TransactionRequest {
434 input_notes,
435 input_notes_args,
436 script_template,
437 expected_output_recipients,
438 expected_future_notes,
439 advice_map,
440 merkle_store,
441 foreign_accounts,
442 expiration_delta,
443 ignore_invalid_input_notes,
444 script_arg,
445 auth_arg,
446 expected_ntx_scripts,
447 })
448 }
449}
450
451pub(crate) fn collect_assets<'a>(
456 assets: impl Iterator<Item = &'a Asset>,
457) -> (BTreeMap<AccountId, u64>, Vec<NonFungibleAsset>) {
458 let mut fungible_balance_map = BTreeMap::new();
459 let mut non_fungible_set = Vec::new();
460
461 assets.for_each(|asset| match asset {
462 Asset::Fungible(fungible) => {
463 let amount = fungible.amount().as_u64();
464 fungible_balance_map
465 .entry(fungible.faucet_id())
466 .and_modify(|balance| *balance += amount)
467 .or_insert(amount);
468 },
469 Asset::NonFungible(non_fungible) => {
470 if !non_fungible_set.contains(non_fungible) {
471 non_fungible_set.push(*non_fungible);
472 }
473 },
474 });
475
476 (fungible_balance_map, non_fungible_set)
477}
478
479impl Default for TransactionRequestBuilder {
480 fn default() -> Self {
481 Self::new()
482 }
483}
484
485#[derive(Debug, Error)]
490pub enum TransactionRequestError {
491 #[error("failed to build the send-notes transaction script")]
492 SendNotesTransactionScriptError(#[from] SendNotesTransactionScriptError),
493 #[error("account error")]
494 AccountError(#[from] AccountError),
495 #[error("asset error")]
496 AssetError(#[from] AssetError),
497 #[error("duplicate input note: note {0} was added more than once to the transaction")]
498 DuplicateInputNote(NoteId),
499 #[error("transaction expiration delta must be greater than zero")]
500 ZeroExpirationDelta,
501 #[error(
502 "the account proof does not contain the required foreign account data; re-fetch the proof and retry"
503 )]
504 ForeignAccountDataMissing,
505 #[error(
506 "foreign account {0} has incompatible visibility; use `ForeignAccount::public()` for public accounts and `ForeignAccount::private()` for private accounts"
507 )]
508 InvalidForeignAccountId(AccountId),
509 #[error(
510 "note {0} cannot be used as an authenticated input: it does not have a valid inclusion proof"
511 )]
512 InputNoteNotAuthenticated(NoteId),
513 #[error("note {0} has already been consumed")]
514 InputNoteAlreadyConsumed(NoteId),
515 #[error(
516 "output note declares sender {actual} but the transaction is executed by account {expected}"
517 )]
518 OutputNoteSenderMismatch { expected: AccountId, actual: AccountId },
519 #[error("invalid transaction script")]
520 InvalidTransactionScript(#[from] TransactionScriptError),
521 #[error("merkle proof error")]
522 MerkleError(#[from] MerkleError),
523 #[error("empty transaction: the request has no input notes and no account state changes")]
524 NoInputNotesNorAccountChange,
525 #[error("note not found: {0}")]
526 NoteNotFound(String),
527 #[error("failed to create note")]
528 NoteCreationError(#[from] NoteError),
529 #[error("note failed validation")]
530 NoteValidationError(#[source] NoteError),
531 #[error("note execution failed")]
532 NoteExecutionError(#[source] NoteError),
533 #[error("failed to build note args")]
534 NoteArgError(#[source] NoteError),
535 #[error("pay-to-ID note must contain at least one asset to transfer")]
536 P2IDNoteWithoutAsset,
537 #[error(
538 "non-fungible asset issued by faucet {0} is not available in the account vault or incoming notes"
539 )]
540 MissingNonFungibleAsset(AccountId),
541 #[error("PSWAP note can only be cancelled by its creator: expected {expected}, got {actual}")]
542 PswapCancelCreatorMismatch { expected: AccountId, actual: AccountId },
543 #[error("error building script")]
544 CodeBuilderError(#[from] CodeBuilderError),
545 #[error("transaction script template error: {0}")]
546 ScriptTemplateError(String),
547 #[error("storage slot {0} not found in account ID {1}")]
548 StorageSlotNotFound(u8, AccountId),
549 #[error("error while building the input notes")]
550 TransactionInputError(#[from] TransactionInputError),
551 #[error("account storage map error")]
552 StorageMapError(#[from] StorageMapError),
553 #[error("asset vault error")]
554 AssetVaultError(#[from] AssetVaultError),
555 #[error(
556 "unsupported authentication scheme ID {0}; supported schemes are: RpoFalcon512 (0) and EcdsaK256Keccak (1)"
557 )]
558 UnsupportedAuthSchemeId(u8),
559}
560
561#[cfg(test)]
565mod tests {
566 use std::vec::Vec;
567
568 use miden_protocol::account::auth::{AuthScheme, PublicKeyCommitment};
569 use miden_protocol::account::{
570 AccountBuilder,
571 AccountComponent,
572 AccountId,
573 AccountType,
574 StorageMapKey,
575 StorageSlotName,
576 };
577 use miden_protocol::asset::FungibleAsset;
578 use miden_protocol::crypto::rand::{FeltRng, RandomCoin};
579 use miden_protocol::note::{NoteTag, NoteType};
580 use miden_protocol::testing::account_id::{
581 ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET,
582 ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
583 ACCOUNT_ID_SENDER,
584 };
585 use miden_protocol::{EMPTY_WORD, Felt, Word};
586 use miden_standards::account::auth::{Approver, AuthSingleSig};
587 use miden_standards::note::P2idNote;
588 use miden_standards::testing::account_component::MockAccountComponent;
589 use miden_tx::utils::serde::{Deserializable, Serializable};
590
591 use super::{TransactionRequest, TransactionRequestBuilder};
592 use crate::rpc::domain::account::AccountStorageRequirements;
593 use crate::transaction::ForeignAccount;
594
595 #[test]
596 fn transaction_request_serialization() {
597 assert_transaction_request_serialization_with(|| {
598 AuthSingleSig::new(Approver::new(
599 PublicKeyCommitment::from(EMPTY_WORD),
600 AuthScheme::Falcon512Poseidon2,
601 ))
602 .into()
603 });
604 }
605
606 #[test]
607 fn transaction_request_serialization_ecdsa() {
608 assert_transaction_request_serialization_with(|| {
609 AuthSingleSig::new(Approver::new(
610 PublicKeyCommitment::from(EMPTY_WORD),
611 AuthScheme::EcdsaK256Keccak,
612 ))
613 .into()
614 });
615 }
616
617 fn assert_transaction_request_serialization_with<F>(auth_component: F)
618 where
619 F: FnOnce() -> AccountComponent,
620 {
621 let sender_id = AccountId::try_from(ACCOUNT_ID_SENDER).unwrap();
622 let target_id =
623 AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE).unwrap();
624 let faucet_id = AccountId::try_from(ACCOUNT_ID_PRIVATE_FUNGIBLE_FAUCET).unwrap();
625 let mut rng = RandomCoin::new(Word::default());
626
627 let mut notes = vec![];
628 for i in 0..6 {
629 let note = P2idNote::builder()
630 .sender(sender_id)
631 .target(target_id)
632 .assets(vec![FungibleAsset::new(faucet_id, 100 + i).unwrap()])
633 .note_type(NoteType::Private)
634 .generate_serial_number(&mut rng)
635 .build()
636 .expect("note creation failed");
637 notes.push(note.into());
638 }
639
640 let mut advice_vec: Vec<(Word, Vec<Felt>)> = vec![];
641 for i in 0u32..10 {
642 advice_vec.push((rng.draw_word(), vec![Felt::from(i)]));
643 }
644
645 let account = AccountBuilder::new(Default::default())
646 .with_component(MockAccountComponent::with_empty_slots())
647 .with_auth_component(auth_component())
648 .account_type(AccountType::Private)
649 .build_existing()
650 .unwrap();
651
652 let tx_request = TransactionRequestBuilder::new()
654 .input_notes(vec![(notes.pop().unwrap(), None)])
655 .expected_output_recipients(vec![notes.pop().unwrap().recipient().clone()])
656 .expected_future_notes(vec![(
657 notes.pop().unwrap().into(),
658 NoteTag::with_account_target(sender_id),
659 )])
660 .extend_advice_map(advice_vec)
661 .foreign_accounts([
662 ForeignAccount::public(
663 target_id,
664 AccountStorageRequirements::new([(
665 StorageSlotName::new("demo::storage_slot").unwrap(),
666 &[StorageMapKey::new(Word::default())],
667 )]),
668 )
669 .unwrap(),
670 ForeignAccount::private(&account).unwrap(),
671 ])
672 .own_output_notes(vec![notes.pop().unwrap(), notes.pop().unwrap()])
673 .script_arg(rng.draw_word())
674 .auth_arg(rng.draw_word())
675 .expected_ntx_scripts(vec![notes.first().unwrap().recipient().script().clone()])
676 .build()
677 .unwrap();
678
679 let mut buffer = Vec::new();
680 tx_request.write_into(&mut buffer);
681
682 let deserialized_tx_request = TransactionRequest::read_from_bytes(&buffer).unwrap();
683 assert_eq!(tx_request, deserialized_tx_request);
684 }
685}