Skip to content

Conversation

@Fumuran
Copy link
Contributor

@Fumuran Fumuran commented Apr 3, 2025

This PR implements a new procedure for the TransactionExecutor which will check the ability for the transaction and the provided notes to be successfully executed (and consumed).

This new procedure utilizes the same events which is used by the benchmark to keep track of the executed and failing notes.

For the well known notes (P2ID, P2IDR and SWAP) we additionally check that the account ID provided as the note input matches the ID of the target account.

TODO:

  • Write the docs about how to use the checker.

Corresponding issue: #831

@Fumuran Fumuran force-pushed the andrew-statefull-check branch from b86dd5f to 11a6537 Compare April 4, 2025 18:29
@Fumuran Fumuran marked this pull request as ready for review April 4, 2025 18:43
@Fumuran Fumuran force-pushed the andrew-statefull-check branch from 446cf71 to 2e22184 Compare April 11, 2025 10:06
Copy link
Contributor

@PhilippGackstatter PhilippGackstatter left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me!

Generally, I think this PR implements a middle-ground between checking well known notes manually and attempting notes execution. I think we should lean in one or the other direction.

I think the manual check for well known notes adds maintenance burden to keep the logic of the those notes in sync between MASM and this checker. Right now, we don't gain much because we still have to execute the well known notes regardless of the manual check. Basically, the check, afaiu, only avoids execution if we can already tell from the manual check that they won't succeed, so only the unhappy path is faster.

So overall I think we have two options:

  1. Check the well known notes fully manually, so that we don't have to execute them in the VM. This justifies the maintenance burden more because we don't have to execute well known notes.
  2. Remove the special well-known note checks and always execute all notes.

My preference is more the latter, because I prefer general solutions over specific ones and due to the well known note checks that we have to keep in sync not only in the checker, but we'd ideally also have to make sure this is tested so that if they fall out of sync we're somehow notified. I guess my main question is, with the fast VM processor, is always executing a big slowdown?

Pinging @bobbinth because I think he might lean more towards option 1.

Comment on lines -70 to -71
# use `push.* drop` instructions before `trace` to make sure that MAST root will be unique
push.91683607 drop
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's also close #1094 once this PR is merged.

/// provided input notes. If so, assert that the note inputs are correct.
///
/// Returns [NoteAccountCompatibility::No] if at least one note has incorrect inputs.
pub fn check_note_inputs(&self) -> (NoteAccountCompatibility, Option<NoteId>) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this have to be a public API, like is it used in the client or somewhere? If not, then I would make it private.

One way or another, it would be nice to encode this guarantee that if NoteAccountCompatibility::No then the Option is Some, None otherwise, because that is not clear without looking at the actual code.

The easiest is to just return ExecutionCheckResult directly from this function, but it's not really correct to return Success variant from here, because we can't really say that it's a success just yet.

Maybe we can just wrap this in a struct like:

enum NoteInputsCheck {
    Maybe,
    No { failed_note_id: NoteId }
}

and then convert this back into a NoteAccountCompatibility in check_notes_consumability.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this have to be a public API, like is it used in the client or somewhere? If not, then I would make it private.

I assumed that potentially the user may want to run this light check only, without executing the scripts, so I left this to be public. But not sure how helpful it would be, especially assuming your head comment (maybe I'll even remove this check entirely).

@Fumuran
Copy link
Contributor Author

Fumuran commented Apr 14, 2025

@PhilippGackstatter Thank you for the comprehensive review!

Check the well known notes fully manually, so that we don't have to execute them in the VM. This justifies the maintenance burden more because we don't have to execute well known notes.

This approach will work only for P2ID and P2IDR notes, but not for the SWAP note. To perform a static check for it we have to have the output notes (to make sure that the income + current balance >= expense), which will be constructed only after transaction execution (see 0xMiden/miden-client#470 (comment)). So probably the second option is the only one which allows us to make sure that all notes will be consumed.

@Fumuran Fumuran force-pushed the andrew-statefull-check branch from 2e22184 to 4ab2230 Compare April 14, 2025 21:16
Copy link
Contributor

@PhilippGackstatter PhilippGackstatter left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the updates!

This approach will work only for P2ID and P2IDR notes, but not for the SWAP note.

Ah, that's right. The SWAP note has dependencies on the rest of the transactions, so it cannot be looked at in isolation.

This to me suggests that we should always execute SWAP notes and avoid implementing any checks in Rust on it, mainly because I think:

  • the note inputs check in itself is not very useful, because there is no incentive for anyone to create a note of any type whose note inputs are incorrect, because that note would never be consumable anyway. And so we will still have to always execute such checked notes in the success case, and it should be very rare to run into the "note inputs incorrect" case.
  • adding a check if there is enough of the requested asset in the account is not 100% safe either, because of the above-mentioned dependencies. If we consume a SWAP note where the requested asset amount is 100, and the account initially has just 50, then we'd fail this check. But there could be a P2ID note executed before the SWAP note that adds 50 to the account, which would make it succeed. So this would result in a false negative, i.e. the check would say it cannot be consumed, but it can in fact be consumed.

For P2ID(R) notes, it may still make sense to have the full logic implemented in Rust, because:

  • While we can only skip execution if there are only P2ID(R) notes in the transaction (or any other future note types that we can implement static checks for), this should still happen quite frequently in practice. For example, if I send an asset to someone else, I will most likely do it using a P2* variant and the receiver will likely claim it in a single transaction where the only input is that note. In those cases, we can completely skip execution, which should result in a significant enough performance benefit to be worth the reimplementation of the logic.

Just to note it: If there is a mix of P2ID(R) notes and other note types in the transaction, we will end up executing in any case in the happy path. In the unhappy path, we will potentially return early before execution, if some of the P2ID(R) logic fails.

So to summarize, my suggestion is to:

  • Remove any Rust checks for SWAP notes and always execute them.
  • Implement the full P2ID(R) note logic in Rust to skip execution entirely, if the transaction consumes notes of only that type.

/// Checks whether there are "well known" notes (`P2ID`, `P2IDR` and `SWAP`) in the list of the
/// provided input notes. If so, assert that the note inputs are correct.
///
/// Returns [NoteAccountCompatibility::No] if at least one note has incorrect inputs.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at this again, I wonder if this should have the same behavior as check_notes_consumability, i.e. return NoteInputsCheck::No with a list of notes that were fine up until that point? I think that'd be more consistent overall, so that the NotesChecker uses a best-effort strategy on all checks.

(This applies only if we keep this in the first place).

Comment on lines 1149 to 1161
assert!(if let NoteAccountExecution::Failure {
failed_note_id,
successful_notes,
error: Some(e),
} = execution_check_result
{
assert_eq!(failed_note_id, failing_note_2.id());
assert_eq!(successful_notes, vec![input_note_ids[0]]);
assert!(matches!(e, TransactionExecutorError::TransactionProgramExecutionFailed { .. }));
true
} else {
false
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: This could be written a bit more concisely with assert_matches! but it's also fine as-is.

/// - If the transaction host can not be created from the provided values.
/// - If the execution of the provided program fails on the stage other than note execution.
#[maybe_async]
pub(crate) fn try_notes_execution(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I understand, there is no way for us to drive the VM in a stateful way, right? What I mean is this:

Even more sophisticated approach (but not sure how much complexity it would add), would be create a "stateful" executor such that we could execute notes incrementally. That is, execute note a first, then execute note b, if execution of note b fails, we could execute note c, and if that works, we'd know that executing notes a and c together will work fine.
#831 (comment)

If b fails, we'd have to continue the execution from the state after a was executed but using c. This would require new VM APIs I guess.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would require new VM APIs I guess.

Yes, for now it won't really work. We should be able to somehow store and load VM state to be able to execute notes one by one: store the state after prologue, then store it after each successful note execution and load the previous state if the execution failed. Not sure though how this can be implemented

@Fumuran Fumuran force-pushed the andrew-statefull-check branch from bb65a68 to c3a7817 Compare April 17, 2025 20:15
@Fumuran
Copy link
Contributor Author

Fumuran commented Apr 17, 2025

Probably the only big thing left is documentation. Not sure though where to place: we check notes against some account, so I can put the paragraph in the Account section or in the Note section of the Protocol documentation, but Note probably would be better.

Also not sure how can we put it: AFAICS we use an upper-level descriptions in the docs, but this check (both stateless and stateful) involves understanding of how it is implemented. Should we just mention that consumability of the input notes against some account could be checked before the execution, without going into the details of how exactly it could be done?

Copy link
Contributor

@bobbinth bobbinth left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! Thank you. I left a few comments inline. I think the biggest thing is to update this PR once #1229 is merged.

So overall I think we have two options:

  1. Check the well known notes fully manually, so that we don't have to execute them in the VM. This justifies the maintenance burden more because we don't have to execute well known notes.
  2. Remove the special well-known note checks and always execute all notes.

My preference is more the latter, because I prefer general solutions over specific ones and due to the well known note checks that we have to keep in sync not only in the checker, but we'd ideally also have to make sure this is tested so that if they fall out of sync we're somehow notified. I guess my main question is, with the fast VM processor, is always executing a big slowdown?

I'd probably keep the fast path for now. I'd like to see if we can evolve to be more modular/customizable - but if that doesn't work out, we can scrap it.

Probably the only big thing left is documentation. Not sure though where to place: we check notes against some account, so I can put the paragraph in the Account section or in the Note section of the Protocol documentation, but Note probably would be better.

I'm actually not sure we need to document it in mdBook. Let's skip this for now.

@Fumuran Fumuran requested a review from bobbinth April 22, 2025 16:01
@Fumuran Fumuran force-pushed the andrew-statefull-check branch from abf43bc to 864b04a Compare April 22, 2025 20:06
Copy link
Contributor

@PhilippGackstatter PhilippGackstatter left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me!

The main comment is the one about the account ID validity which I think we should address. The rest is optional.

}

Self::check_input_account_id(note_inputs, target_account_id)
let recall_hight: Result<u32, _> = note_inputs[2].try_into();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let recall_hight: Result<u32, _> = note_inputs[2].try_into();
let recall_height: Result<u32, _> = note_inputs[2].try_into();

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this still needs to be addressed.

Comment on lines 179 to 182
match recall_hight {
// invalid input value: the block number does not fit in u32
Err(_) => NoteAccountCompatibility::No,
Ok(recall_hight) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be slightly easier to read with a let-else:

let Ok(recall_height) = recall_height else {
  return NoteAccountCompatibility::No;
};
// ...

Comment on lines 218 to 219
let inputs_account_id = AccountId::try_from([account_id_felts[1], account_id_felts[0]])
.expect("invalid account ID felts");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we can assume that the felts are valid account ID felts. By accident or malice, a note creator could put incorrect values into the note inputs. So in the error case we should return NoteAccountCompatibility::No instead of panicking.

/// The check is performed using the [NoteConsumptionChecker::check_notes_consumability] procedure.
/// Essentially runs the transaction to make sure that provided input notes could be consumed by the
/// account.
pub struct NoteConsumptionChecker<'a>(pub &'a TransactionExecutor);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we make the field private and add a constructor instead?

Copy link
Contributor

@bobbinth bobbinth left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! Thank you! I left a few small comments inline - but it also seems like several of @PhilippGackstatter's comments still need to be addressed.

}

Self::check_input_account_id(note_inputs, target_account_id)
let recall_hight: Result<u32, _> = note_inputs[2].try_into();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this still needs to be addressed.

@Fumuran Fumuran requested a review from bobbinth April 26, 2025 19:18
Copy link
Contributor

@bobbinth bobbinth left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! I left a few more comments inline.

Comment on lines 69 to 70
// we need to execute the transaction if compatibility is unclear
NoteAccountCompatibility::Maybe => break,
Copy link
Contributor

@PhilippGackstatter PhilippGackstatter Apr 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WellKnownNote::check_note_inputs is guaranteed to return Yes or No in this scenario because we never run it on a SWAP note.

However, I don't know if it's a good idea to turn this into an unreachable!, because we might add more well-known notes in the future and then this subtle assumption could break, unless we add a continue for notes that could return Maybe like we have for SWAP now.

As of right now, we should never run into this case though. Defensively, I would change the logic so that if it happens we continue instead of break. Since we do not add this note to the successful notes, the length check later will fail and so we will execute in the VM anyway, right? That way, we do not abort too early if there are still P2ID(R) notes to be checked and it should be correct albeit not optimal, even if we forget to update the code when a new well known note is added in the future.

Would that work? If so, let's add an explanation of this as a comment in code.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would change the logic so that if it happens we continue instead of break

Right, I agree, I just missed this break when I was refactoring. In that case this should be equivalent to the
if let WellKnownNote::SWAP = well_known_note { continue }; branch, which leads to the tx execution.

Would that work? If so, let's add an explanation of this as a comment in code.

Yep, I'll add the explanation

@Fumuran Fumuran requested a review from bobbinth May 3, 2025 21:15
Copy link
Contributor

@bobbinth bobbinth left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! Thank you! I added a couple of minor comments inline. Once these are addressed, we are good to merge.

@bobbinth bobbinth merged commit 3ffa3ac into next May 5, 2025
16 checks passed
@bobbinth bobbinth deleted the andrew-statefull-check branch May 5, 2025 19:30
@bobbinth bobbinth mentioned this pull request May 19, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants