-
Notifications
You must be signed in to change notification settings - Fork 99
Stateful execution checker #1269
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
b86dd5f to
11a6537
Compare
446cf71 to
2e22184
Compare
PhilippGackstatter
left a comment
There was a problem hiding this 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:
- 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.
- 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.
| # use `push.* drop` instructions before `trace` to make sure that MAST root will be unique | ||
| push.91683607 drop |
There was a problem hiding this comment.
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>) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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).
|
@PhilippGackstatter Thank you for the comprehensive review!
This approach will work only for |
2e22184 to
4ab2230
Compare
PhilippGackstatter
left a comment
There was a problem hiding this 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. |
There was a problem hiding this comment.
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).
crates/miden-tx/src/tests/mod.rs
Outdated
| 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 | ||
| }); |
There was a problem hiding this comment.
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.
crates/miden-tx/src/executor/mod.rs
Outdated
| /// - 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( |
There was a problem hiding this comment.
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
afirst, then execute noteb, if execution of notebfails, we could execute notec, and if that works, we'd know that executing notesaandctogether 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.
There was a problem hiding this comment.
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
bb65a68 to
c3a7817
Compare
|
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 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? |
bobbinth
left a comment
There was a problem hiding this 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:
- 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.
- 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
Accountsection or in theNotesection of theProtocoldocumentation, butNoteprobably would be better.
I'm actually not sure we need to document it in mdBook. Let's skip this for now.
abf43bc to
864b04a
Compare
PhilippGackstatter
left a comment
There was a problem hiding this 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(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| let recall_hight: Result<u32, _> = note_inputs[2].try_into(); | |
| let recall_height: Result<u32, _> = note_inputs[2].try_into(); |
There was a problem hiding this comment.
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.
| match recall_hight { | ||
| // invalid input value: the block number does not fit in u32 | ||
| Err(_) => NoteAccountCompatibility::No, | ||
| Ok(recall_hight) => { |
There was a problem hiding this comment.
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;
};
// ...| let inputs_account_id = AccountId::try_from([account_id_felts[1], account_id_felts[0]]) | ||
| .expect("invalid account ID felts"); |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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?
bobbinth
left a comment
There was a problem hiding this 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(); |
There was a problem hiding this comment.
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.
bobbinth
left a comment
There was a problem hiding this 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.
| // we need to execute the transaction if compatibility is unclear | ||
| NoteAccountCompatibility::Maybe => break, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
continueinstead ofbreak
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
bobbinth
left a comment
There was a problem hiding this 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.
This PR implements a new procedure for the
TransactionExecutorwhich 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,P2IDRandSWAP) we additionally check that the account ID provided as the note input matches the ID of the target account.TODO:
Corresponding issue: #831