miden_tx/executor/notes_checker.rs
1use alloc::collections::BTreeMap;
2use alloc::vec::Vec;
3
4use miden_processor::ExecutionError;
5use miden_processor::advice::AdviceInputs;
6use miden_protocol::account::AccountId;
7use miden_protocol::block::BlockNumber;
8use miden_protocol::note::Note;
9use miden_protocol::transaction::{
10 InputNote,
11 InputNotes,
12 TransactionArgs,
13 TransactionInputs,
14 TransactionKernel,
15};
16use miden_standards::note::{NoteConsumptionStatus, StandardNote};
17
18use super::{ProgramExecutor, TransactionExecutor};
19use crate::auth::TransactionAuthenticator;
20use crate::errors::TransactionCheckerError;
21use crate::executor::map_execution_error;
22use crate::{DataStore, NoteCheckerError, TransactionExecutorError};
23
24// CONSTANTS
25// ================================================================================================
26
27/// Maximum number of notes that can be checked at once.
28///
29/// Fixed at an amount that should keep each run of note consumption checking to a maximum of ~50ms.
30pub const MAX_NUM_CHECKER_NOTES: usize = 20;
31
32// NOTE CONSUMPTION INFO
33// ================================================================================================
34
35/// Represents a successfully consumed note along with the number of cycles it took to execute.
36#[derive(Debug)]
37pub struct SuccessfulNote {
38 note: Note,
39 num_cycles: usize,
40}
41
42impl SuccessfulNote {
43 /// Constructs a new `SuccessfulNote`.
44 pub fn new(note: Note, num_cycles: usize) -> Self {
45 Self { note, num_cycles }
46 }
47
48 /// Returns a reference to the note.
49 pub fn note(&self) -> &Note {
50 &self.note
51 }
52
53 /// Returns the number of cycles consumed during execution.
54 pub fn num_cycles(&self) -> usize {
55 self.num_cycles
56 }
57}
58
59/// Represents a failed note consumption.
60#[derive(Debug)]
61pub struct FailedNote {
62 note: Note,
63 error: TransactionExecutorError,
64 /// The number of cycles consumed by the note before it failed.
65 ///
66 /// This is `Some` when the failure was due to exceeding the cycle limit, and `None`
67 /// for other error types where the cycle count is not meaningful.
68 num_cycles: Option<usize>,
69}
70
71impl FailedNote {
72 /// Constructs a new `FailedNote`.
73 pub fn new(note: Note, error: TransactionExecutorError, num_cycles: Option<usize>) -> Self {
74 Self { note, error, num_cycles }
75 }
76
77 /// Returns a reference to the note.
78 pub fn note(&self) -> &Note {
79 &self.note
80 }
81
82 /// Returns a reference to the error.
83 pub fn error(&self) -> &TransactionExecutorError {
84 &self.error
85 }
86
87 /// Returns the number of cycles consumed before failure, if available.
88 ///
89 /// This is `Some` when the failure was due to exceeding the cycle limit, and `None`
90 /// for other error types where the cycle count is not meaningful.
91 pub fn num_cycles(&self) -> Option<usize> {
92 self.num_cycles
93 }
94}
95
96/// Contains information about the successful and failed consumption of notes.
97#[derive(Default, Debug)]
98pub struct NoteConsumptionInfo {
99 successful: Vec<SuccessfulNote>,
100 failed: Vec<FailedNote>,
101}
102
103impl NoteConsumptionInfo {
104 /// Creates a new [`NoteConsumptionInfo`] instance with the given successful notes.
105 pub fn new_successful(successful: Vec<SuccessfulNote>) -> Self {
106 Self { successful, ..Default::default() }
107 }
108
109 /// Creates a new [`NoteConsumptionInfo`] instance with the given successful and failed notes.
110 pub fn new(successful: Vec<SuccessfulNote>, failed: Vec<FailedNote>) -> Self {
111 Self { successful, failed }
112 }
113
114 /// Returns a reference to the successfully consumed notes.
115 pub fn successful(&self) -> &[SuccessfulNote] {
116 &self.successful
117 }
118
119 /// Returns a reference to the failed notes.
120 pub fn failed(&self) -> &[FailedNote] {
121 &self.failed
122 }
123
124 /// Consumes the struct and returns the successful and failed notes.
125 pub fn into_parts(self) -> (Vec<SuccessfulNote>, Vec<FailedNote>) {
126 (self.successful, self.failed)
127 }
128}
129
130// NOTE CONSUMPTION CHECKER
131// ================================================================================================
132
133/// This struct performs input notes check against provided target account.
134///
135/// The check is performed using the [NoteConsumptionChecker::check_notes_consumability] procedure.
136/// Essentially runs the transaction to make sure that provided input notes could be consumed by the
137/// account.
138pub struct NoteConsumptionChecker<'a, STORE, AUTH, EXEC: ProgramExecutor>(
139 &'a TransactionExecutor<'a, 'a, STORE, AUTH, EXEC>,
140);
141
142impl<'a, STORE, AUTH, EXEC> NoteConsumptionChecker<'a, STORE, AUTH, EXEC>
143where
144 STORE: DataStore + Sync,
145 AUTH: TransactionAuthenticator + Sync,
146 EXEC: ProgramExecutor,
147{
148 /// Creates a new [`NoteConsumptionChecker`] instance with the given transaction executor.
149 pub fn new(tx_executor: &'a TransactionExecutor<'a, 'a, STORE, AUTH, EXEC>) -> Self {
150 NoteConsumptionChecker(tx_executor)
151 }
152
153 /// Checks whether some set of the provided input notes could be consumed by the provided
154 /// account by executing the transaction with varying combination of notes.
155 ///
156 /// This function attempts to find the maximum set of notes that can be successfully executed
157 /// together by the target account.
158 ///
159 /// Because of the runtime complexity involved in this function, a limited range of
160 /// [`MAX_NUM_CHECKER_NOTES`] input notes is allowed.
161 ///
162 /// If some notes succeed and others fail, the failed notes are removed from the candidate set
163 /// and the remaining notes (successful + unattempted) are retried in the next iteration. This
164 /// process continues until either all remaining notes succeed or no notes can be successfully
165 /// executed
166 ///
167 /// For example, given notes A, B, C, D, E, the execution flow would be as follows:
168 /// - Try [A, B, C, D, E] → A, B succeed, C fails → Remove C, try again.
169 /// - Try [A, B, D, E] → A, B, D succeed, E fails → Remove E, try again.
170 /// - Try [A, B, D] → All succeed → Return successful=[A, B, D], failed=[C, E].
171 ///
172 /// If a failure occurs at the epilogue phase of the transaction execution, the relevant set of
173 /// otherwise-successful notes are retried in various combinations in an attempt to find a
174 /// combination that passes the epilogue phase successfully.
175 ///
176 /// Returns a list of successfully consumed notes and a list of failed notes.
177 pub async fn check_notes_consumability(
178 &self,
179 target_account_id: AccountId,
180 block_ref: BlockNumber,
181 mut notes: Vec<Note>,
182 tx_args: TransactionArgs,
183 ) -> Result<NoteConsumptionInfo, NoteCheckerError> {
184 let num_notes = notes.len();
185 if num_notes == 0 || num_notes > MAX_NUM_CHECKER_NOTES {
186 return Err(NoteCheckerError::InputNoteCountOutOfRange(num_notes));
187 }
188 // Ensure standard notes are ordered first.
189 notes.sort_unstable_by_key(|note| {
190 StandardNote::from_script_root(note.script().root()).is_none()
191 });
192
193 let notes = InputNotes::from(notes);
194 let tx_inputs = self
195 .0
196 .prepare_tx_inputs(target_account_id, block_ref, notes, tx_args)
197 .await
198 .map_err(NoteCheckerError::TransactionPreparation)?;
199
200 // Attempt to find an executable set of notes.
201 self.find_executable_notes_by_elimination(tx_inputs).await
202 }
203
204 /// Checks whether the provided input note could be consumed by the provided account by
205 /// executing a transaction at the specified block height.
206 ///
207 /// This function takes into account the possibility that the signatures may not be loaded into
208 /// the transaction context and returns the [`NoteConsumptionStatus`] result accordingly.
209 ///
210 /// This function first applies the static analysis of the provided note, and if it doesn't
211 /// reveal any errors next it tries to execute the transaction. Based on the execution result,
212 /// it either returns a [`NoteCheckerError`] or the [`NoteConsumptionStatus`]: depending on
213 /// whether the execution succeeded, failed in the prologue, during the note execution process
214 /// or in the epilogue.
215 pub async fn can_consume(
216 &self,
217 target_account_id: AccountId,
218 block_ref: BlockNumber,
219 note: InputNote,
220 tx_args: TransactionArgs,
221 ) -> Result<NoteConsumptionStatus, NoteCheckerError> {
222 // Return the consumption status if we manage to determine it from the standard note
223 if let Some(standard_note) = StandardNote::from_script_root(note.note().script().root())
224 && let Some(consumption_status) =
225 standard_note.is_consumable(note.note(), target_account_id, block_ref)
226 {
227 return Ok(consumption_status);
228 }
229
230 // Prepare transaction inputs.
231 let mut tx_inputs = self
232 .0
233 .prepare_tx_inputs(
234 target_account_id,
235 block_ref,
236 InputNotes::new_unchecked(vec![note]),
237 tx_args,
238 )
239 .await
240 .map_err(NoteCheckerError::TransactionPreparation)?;
241
242 // try to consume the provided note
243 match self.try_execute_notes(&mut tx_inputs).await {
244 // execution succeeded
245 Ok(_cycle_counts) => Ok(NoteConsumptionStatus::Consumable),
246 Err(tx_checker_error) => {
247 match tx_checker_error {
248 // execution failed on the preparation stage, before we actually executed the tx
249 TransactionCheckerError::TransactionPreparation(e) => {
250 Err(NoteCheckerError::TransactionPreparation(e))
251 },
252 // execution failed during the prologue
253 TransactionCheckerError::PrologueExecution(e) => {
254 Err(NoteCheckerError::PrologueExecution(e))
255 },
256 // execution failed during the note processing
257 TransactionCheckerError::NoteExecution { .. } => {
258 Ok(NoteConsumptionStatus::UnconsumableConditions)
259 },
260 // execution failed during the epilogue
261 TransactionCheckerError::EpilogueExecution {
262 error: epilogue_error, ..
263 } => Ok(handle_epilogue_error(epilogue_error)),
264 }
265 },
266 }
267 }
268
269 // HELPER METHODS
270 // --------------------------------------------------------------------------------------------
271
272 /// Finds a set of executable notes and eliminates failed notes from the list in the process.
273 ///
274 /// The result contains some combination of the input notes partitioned by whether they
275 /// succeeded or failed to execute.
276 async fn find_executable_notes_by_elimination(
277 &self,
278 mut tx_inputs: TransactionInputs,
279 ) -> Result<NoteConsumptionInfo, NoteCheckerError> {
280 let mut candidate_notes = tx_inputs
281 .input_notes()
282 .iter()
283 .map(|note| note.clone().into_note())
284 .collect::<Vec<_>>();
285 let mut failed_notes = Vec::new();
286
287 // Attempt to execute notes in a loop. Reduce the set of notes based on failures until
288 // either a set of notes executes without failure or the set of notes cannot be
289 // further reduced.
290 loop {
291 // Execute the candidate notes.
292 tx_inputs.set_input_notes(candidate_notes.clone());
293 match self.try_execute_notes(&mut tx_inputs).await {
294 Ok(cycle_counts) => {
295 // A full set of successful notes has been found.
296 let successful = candidate_notes
297 .into_iter()
298 .zip(cycle_counts)
299 .map(|(note, num_cycles)| SuccessfulNote::new(note, num_cycles))
300 .collect();
301 return Ok(NoteConsumptionInfo::new(successful, failed_notes));
302 },
303 Err(TransactionCheckerError::NoteExecution {
304 failed_note_index,
305 error,
306 failed_note_cycle_count,
307 ..
308 }) => {
309 // SAFETY: Failed note index is in bounds of the candidate notes.
310 let failed_note = candidate_notes.remove(failed_note_index);
311 failed_notes.push(FailedNote::new(failed_note, error, failed_note_cycle_count));
312
313 // All possible candidate combinations have been attempted.
314 if candidate_notes.is_empty() {
315 return Ok(NoteConsumptionInfo::new(Vec::new(), failed_notes));
316 }
317 // Continue and process the next set of candidates.
318 },
319 Err(TransactionCheckerError::EpilogueExecution { .. }) => {
320 let consumption_info = self
321 .find_largest_executable_combination(
322 candidate_notes,
323 failed_notes,
324 tx_inputs,
325 )
326 .await;
327 return Ok(consumption_info);
328 },
329 Err(TransactionCheckerError::PrologueExecution(err)) => {
330 return Err(NoteCheckerError::PrologueExecution(err));
331 },
332 Err(TransactionCheckerError::TransactionPreparation(err)) => {
333 return Err(NoteCheckerError::TransactionPreparation(err));
334 },
335 }
336 }
337 }
338
339 /// Attempts to find the largest possible combination of notes that can execute successfully
340 /// together.
341 ///
342 /// This method incrementally tries combinations of increasing size (1 note, 2 notes, 3 notes,
343 /// etc.) and builds upon previously successful combinations to find the maximum executable
344 /// set.
345 async fn find_largest_executable_combination(
346 &self,
347 mut remaining_notes: Vec<Note>,
348 mut failed_notes: Vec<FailedNote>,
349 mut tx_inputs: TransactionInputs,
350 ) -> NoteConsumptionInfo {
351 let mut successful_notes = Vec::new();
352 let mut successful_cycle_counts = Vec::new();
353 let mut failed_note_index = BTreeMap::new();
354
355 // Iterate by note count: try 1 note, then 2, then 3, etc.
356 for size in 1..=remaining_notes.len() {
357 // Can't build a combination of size N without at least N-1 successful notes.
358 if successful_notes.len() < size - 1 {
359 break;
360 }
361
362 // Try adding each remaining note to the current successful combination.
363 for (idx, note) in remaining_notes.iter().enumerate() {
364 successful_notes.push(note.clone());
365
366 tx_inputs.set_input_notes(successful_notes.clone());
367 match self.try_execute_notes(&mut tx_inputs).await {
368 Ok(cycle_counts) => {
369 // The successfully added note might have failed earlier. Remove it from the
370 // failed list.
371 failed_note_index.remove(¬e.id());
372 // Store the cycle counts from the latest successful execution.
373 successful_cycle_counts = cycle_counts;
374 // This combination succeeded; remove the most recently added note from
375 // the remaining set.
376 remaining_notes.remove(idx);
377 break;
378 },
379 Err(error) => {
380 // This combination failed; remove the last note from the test set and
381 // continue to next note.
382 let failed_note =
383 successful_notes.pop().expect("successful notes should not be empty");
384
385 // Extract the failed note's cycle count if available.
386 let num_cycles = match &error {
387 TransactionCheckerError::NoteExecution {
388 failed_note_cycle_count,
389 ..
390 } => *failed_note_cycle_count,
391 _ => None,
392 };
393
394 // Record the failed note (overwrite previous failures for the relevant
395 // note).
396 failed_note_index.insert(
397 failed_note.id(),
398 FailedNote::new(failed_note, error.into(), num_cycles),
399 );
400 },
401 }
402 }
403 }
404
405 // Pair successful notes with their cycle counts from the last successful execution.
406 let successful = successful_notes
407 .into_iter()
408 .zip(successful_cycle_counts)
409 .map(|(note, num_cycles)| SuccessfulNote::new(note, num_cycles))
410 .collect();
411
412 // Append failed notes to the list of failed notes provided as input.
413 failed_notes.extend(failed_note_index.into_values());
414 NoteConsumptionInfo::new(successful, failed_notes)
415 }
416
417 /// Attempts to execute a transaction with the provided input notes.
418 ///
419 /// This method executes the full transaction pipeline including prologue, note execution,
420 /// and epilogue phases. It returns `Ok(cycle_counts)` if all notes are successfully consumed
421 /// (where `cycle_counts` contains the number of cycles for each note), or a specific
422 /// [`TransactionCheckerError`] indicating where and why the execution failed. The order of the
423 /// returned `cycle_counts` is guaranteed to match the order of the input notes.
424 async fn try_execute_notes(
425 &self,
426 tx_inputs: &mut TransactionInputs,
427 ) -> Result<Vec<usize>, TransactionCheckerError> {
428 if tx_inputs.input_notes().is_empty() {
429 return Ok(Vec::new());
430 }
431
432 let (mut host, stack_inputs, advice_inputs) =
433 self.0
434 .prepare_transaction(tx_inputs)
435 .await
436 .map_err(TransactionCheckerError::TransactionPreparation)?;
437
438 let processor = EXEC::new(stack_inputs, advice_inputs, self.0.exec_options);
439 let result = processor
440 .execute(&TransactionKernel::main(), &mut host)
441 .await
442 .map_err(map_execution_error);
443
444 match result {
445 Ok(execution_output) => {
446 let cycle_counts = host
447 .tx_progress()
448 .note_execution()
449 .iter()
450 .map(|(_, interval)| interval.len())
451 .collect();
452
453 // Set the advice inputs from the successful execution as advice inputs for
454 // reexecution. This avoids calls to the data store (to load data lazily) that have
455 // already been done as part of this execution.
456 let (_, advice_map, merkle_store, _) = execution_output.advice.into_parts();
457 let advice_inputs = AdviceInputs {
458 map: advice_map,
459 store: merkle_store,
460 ..Default::default()
461 };
462 tx_inputs.set_advice_inputs(advice_inputs);
463 Ok(cycle_counts)
464 },
465 Err(error) => {
466 let notes = host.tx_progress().note_execution();
467
468 // Empty notes vector means that we didn't process the notes, so an error
469 // occurred.
470 if notes.is_empty() {
471 return Err(TransactionCheckerError::PrologueExecution(error));
472 }
473
474 let ((_, last_note_interval), success_notes) =
475 notes.split_last().expect("notes vector is not empty because of earlier check");
476
477 // If the interval end of the last note is specified, then an error occurred after
478 // notes processing. All notes executed successfully in this case.
479 if last_note_interval.end().is_some() {
480 let successful_notes_cycle_counts =
481 notes.iter().map(|(_, interval)| interval.len()).collect();
482 Err(TransactionCheckerError::EpilogueExecution {
483 error,
484 successful_notes_cycle_counts,
485 })
486 } else {
487 // Return the index of the failed note.
488 let failed_note_index = success_notes.len();
489 let successful_notes_cycle_counts =
490 success_notes.iter().map(|(_, interval)| interval.len()).collect();
491
492 // Compute the failed note's cycle count when the failure was due to
493 // exceeding the cycle limit. In this case, the note's interval has a
494 // start but no end, and the total cycles consumed equals the max allowed.
495 let failed_note_cycle_count = match &error {
496 TransactionExecutorError::TransactionProgramExecutionFailed(
497 ExecutionError::CycleLimitExceeded(max_cycles),
498 ) => last_note_interval
499 .start()
500 .map(|start| *max_cycles as usize - usize::from(start)),
501 _ => None,
502 };
503
504 Err(TransactionCheckerError::NoteExecution {
505 failed_note_index,
506 error,
507 successful_notes_cycle_counts,
508 failed_note_cycle_count,
509 })
510 }
511 },
512 }
513 }
514}
515
516// HELPER FUNCTIONS
517// ================================================================================================
518
519/// Handle the epilogue error during the note consumption check in the `can_consume` method.
520///
521/// The goal of this helper function is to handle the cases where the account couldn't consume the
522/// note because of some epilogue check failure, e.g. absence of the authenticator.
523fn handle_epilogue_error(epilogue_error: TransactionExecutorError) -> NoteConsumptionStatus {
524 match epilogue_error {
525 // `Unauthorized` is returned for the multisig accounts if the transaction doesn't have
526 // enough signatures.
527 TransactionExecutorError::Unauthorized(_)
528 // `MissingAuthenticator` is returned for the account with the basic auth if the
529 // authenticator was not provided to the executor (UnreachableAuth).
530 | TransactionExecutorError::MissingAuthenticator => {
531 // Both these cases signal that there is a probability that the provided note could be
532 // consumed if the authentication is provided.
533 NoteConsumptionStatus::ConsumableWithAuthorization
534 },
535 // TODO: apply additional checks to get the verbose error reason
536 _ => NoteConsumptionStatus::UnconsumableConditions,
537 }
538}