Skip to content
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

[FastX adapter/verifier] Implement Move module initializers #337

Merged
merged 10 commits into from
Feb 11, 2022

Conversation

awelc
Copy link
Contributor

@awelc awelc commented Feb 1, 2022

This is the first step towards implementing task #90, that is putting together a skeleton of a testing framework for the adapter that will simplify testing of the new features as the code testing new functionality can be now (more) easily specified in the source files.

@awelc awelc requested a review from sblackshear February 1, 2022 23:12
@awelc
Copy link
Contributor Author

awelc commented Feb 1, 2022

@sblackshear - I was wondering if this structure makes sense. In particular, I made the new tests part of "unit testing" even though they are using external resources (source files) - I think it should be OK but it would be good to get a second opinion before I create more of those.

@sblackshear
Copy link
Collaborator

@sblackshear - I was wondering if this structure makes sense. In particular, I made the new tests part of "unit testing" even though they are using external resources (source files) - I think it should be OK but it would be good to get a second opinion before I create more of those.

Yes, this looks great to me! We might considering calling the second src in (e.g.) fastx_programmability/adapter/src/unit_tests/src/simple_call/sources/M1.move something like data or test_data to emphasize that there aren't any Rust source files in there.

@awelc
Copy link
Contributor Author

awelc commented Feb 2, 2022

Yes, this looks great to me! We might considering calling the second src in (e.g.) fastx_programmability/adapter/src/unit_tests/src/simple_call/sources/M1.move something like data or test_data to emphasize that there aren't any Rust source files in there.

Good idea. I was wondering about it myself, and based on your suggestion renamed it to data (as test_data would arguably be a bit redundant I think).

@awelc awelc self-assigned this Feb 5, 2022
@awelc awelc requested a review from lxfind February 5, 2022 04:04
@awelc
Copy link
Contributor Author

awelc commented Feb 5, 2022

I have pushed my first take at running module initializers even though it does not work 100% yet (though it passes all the tests).

The current implementation appears to run the code in the initializers but does not seem to be propagating all the effects correctly. In particular test_publish_init should result in an object being created as a result of running the initializer but assert_eq!(storage.created().len(), 1); will fail. Furthermore, even the package object does not seem to be making into the "created" set in these cases.

Even though this requires further investigation, I decided to push this commit so that @sblackshear and/or @lxfind can take a look. I went with @lxfind's suggestion on how to propagate effects across sessions , rather than going with what @sblackshear suggested that is to do everything within a single session. The main reason for this was that I was much further along the way towards multi-session approach and if this one is acceptable, then perhaps we can continue going this way.

Additionally, there are some minor refactorings that should probably happen here (e.g., to avoid some code duplication), but I did not want to go overboard with these considering that it's not even clear if this is the way to go.

let function = match Function::new_from_name(module, INIT_FN_NAME) {
Some(v) => v,
None => return false,
};
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think should also check that function:

  • has no return value
  • has no type parameters
  • is private

We may also want to consider a bytecode verifier pass that checks this + checks that init is not called from anywhere.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

First, I am sorry I was not more explicit about it, but this revision is definitely not final - I wanted to put out there just in case the route it takes is not the one we want (the alternative being the single-session approach with execute_function instead of execute_function_for_effects). If this one looks reasonable, then more work is certainly required, including fixing current deficiencies, verifier additions, more tests, etc.

With respect to this particular code fragment, I got so excited that I found code checking the type of the the TxContext parameter , now refactored to is_param_context (I wrote my own checks before...), that I missed the other conditions. Will add!

Copy link
Contributor

Choose a reason for hiding this comment

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

Is there a reason we make the check here, instead of adding a verifier to check that any function named init should follow this rule to avoid confusions?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The answer is that it may be seen as debatable to (for example) forbid all functions with the name init anywhere in the code.

As it is, we have flexibility to introduce checks in the verifier that are as strong or as weak as we want without affecting the execution path.

Copy link
Contributor

Choose a reason for hiding this comment

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

Indeed this is debatable. Here are my thoughts on why I think we should make it strict: It will make education a lot easier.
Imagine the two versions in our dev doc:
"init function is a reserved function and used to define module initializers. An init function must have a specific format otherwise an error will occur".

vs

"If you defined a function called init, and if it has the following 6 properties, it will be called during module initialization; otherwise it won't. And you won't be able to tell until you actually try to run it"

I think it can be dangerous to be too flexible here, since it's hard to reason about the code and can lead to mistakes and confusions.
The cost of not allowing a function named init is fairly minimum (we could also consider other names that's less common to avoid conflict too, such as __init__, or something along the line).
Happy to hear your thoughts and @sblackshear 's

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@lxfind, I am with you there. In fact in my earlier discussions with @sblackshear I expressed the same preference. Still, we decided to do the separation for greater flexibility, and also to allow postponing implementation of the verifier pass until after the GDC deadline (can of course still be done before if we decide it so!).

Copy link
Contributor

Choose a reason for hiding this comment

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

Got it. In that case, can we file an issue to track it? (basically, for anything we want to come back on, either add a TODO or file an issue (preferred)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done in #438.

@awelc awelc marked this pull request as ready for review February 10, 2022 06:34
Copy link
Collaborator

@sblackshear sblackshear left a comment

Choose a reason for hiding this comment

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

This looks great for the most part! My only concerns of note are about the approach to updating TxContext and the gas_budget: 0 in the CLI.

@@ -383,6 +383,10 @@ enum ClientCommands {

/// ID of the gas object for gas payment, in 20 bytes Hex string
gas_object_id: ObjectID,

/// gas budget for running module initializers
#[structopt(default_value = "0")]
Copy link
Collaborator

Choose a reason for hiding this comment

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

  • I think a default value of 0 will make any attempt to publish fail--something bigger might make sense?
  • Are there tests that exercise publishing from the CLI?

Copy link
Contributor Author

@awelc awelc Feb 10, 2022

Choose a reason for hiding this comment

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

If no initializers are executed, then publishing with 0 gas budget will succeed - adapter_tests::test_simple_call (now) checks this particular case.

@@ -182,16 +182,19 @@ impl Storage for AuthorityTemporaryStore {
}

fn read_object(&self, id: &ObjectID) -> Option<Object> {
match self.objects.get(id) {
match self.written.get(id) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is changing the behavior of this function from "read the state of the object before executing the tx" to "read the state of the object after executing the tx, but before committing to persistent storage". This may very well be ok, but CC @lxfind @gdanezis to double check on this. Also, I think we should add a comment explaining what this does (pre-existing issue, but will help avoid some future confusion).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added @gdanezis as a reviewer (@lxfind already is one). This problem has also been reported in #394.

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 wonder if we should return nothing if the object is in the deleted set. That being said, this only matters (for now) when running initializers and there is a limited amount of stuff that can be done in those as they are parameter-less, so perhaps it's not strictly necessary.

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 we need to clearly define (with comments) what the current new assumptions are and what could happen.
We know that we could have read after write now. But is it possible to have delete after write? Read after delete?
Once we define these assumptions, let's also assert them in the right place to make sure they are not violated.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Even the original set of assumptions (e.g., why we expected only either a write or an update to the same object but not both) are a bit unclear to me, so I was not sure what is needed here.

Perhaps we should go for the most general spec? That is:

  1. An object can be only in either deleted or in written at any given time.
  2. When reading an object:
  • check if it's deleted and, if so, return None
  • return its value in written if any
  • return its value in objects if any
  • return None

Though I am not sure if we even need 1 if we have 2.

Copy link
Contributor

Choose a reason for hiding this comment

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

Based on the current need (i.e. module initializers), I think we only have:

  1. Read after write is OK
  2. Nothing else is permitted (add assertions for read after delete, or delete after write, or write after delete)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

SG

authority_clients: HashMap<AuthorityName, LocalAuthorityClient>,
committee: Committee,
) -> ClientState<LocalAuthorityClient> {
let (admin, admin_key) = get_key_pair_from_bytes(&[
Copy link
Collaborator

Choose a reason for hiding this comment

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

I don't have good understanding of the utilities available in the client tests, but the authority tests have get_key_pair(), which uses a seeded RNG to get an arbitrary (but deterministic) keypair. I think that is slightly cleaner than encoding the key bytes manually.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The thing is that the code being used here involves checks for a specific admin address and this address is both embedded in test source files and in the testing code (which I did not write - I just adapted testing code to work with module initializers).

// (e.g., to reflect number of created objects) and use it
// to update the existing &mut TxContext instance.
let ctx_bytes = mutable_ref_values.pop().unwrap();
let updated_ctx: TxContext = bcs::from_bytes(ctx_bytes.as_slice()).unwrap();
Copy link
Collaborator

Choose a reason for hiding this comment

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

One idea for how to do this without paying for this deserialization or introducing the possibility of an invalid update error:

  • Inside process_successful_execution, we will know the # of new objects created by this tx
  • We could pass the TxContext into there and do ctx.objects_created += num_objects_created.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Actually, ignore my suggestion--I don't think it's safe. The TxContext counter actually records the number of ID's created by the tx; the number of objects created can be smaller than this. A note that this update is only required on the publish code path + that we might want to move it there to avoid the overhead in call sometime in the future seems useful, though.

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 was thinking on whether there is some way to compute the number of counter increments without reading the actual TxContext object but, as @sblackshear observed, I was not sure how safe/robust it would be.

I have, however put a conditional that takes the TxContext update path only when the helper function is executed during publishing (and not during "regular" Move call).

Ok(ExecutionStatus::Failure { gas_used, error }) => exec_failure!(gas_used, *error),
Err(err) => exec_failure!(gas::MIN_MOVE_CALL_GAS, err),
};
// TODO: should we decrease gas budget on subsequent calls?
Copy link
Collaborator

Choose a reason for hiding this comment

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

Yes--I think we can probably remove this comment (unless I misunderstand). It does seem like we should account for the possibility of current_gas_budget falling below 0 here, though?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Comment removed and I put an explicit overflow check to avoid panicking in case current_gas_budget < gas_used - the actual out-of-gas error will then be generated inside of the VM.

// try objects updated in temp memory first
match self.temporary.updated.get(id).cloned() {
Some(o) => Some(o),
// try objects creted in temp memory
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
// try objects creted in temp memory
// try objects created in temp memory

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done, thank you!


const INIT_FN_NAME: &IdentStr = ident_str!("init");

pub fn module_with_init(module: &CompiledModule) -> bool {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nit: I would find something like module_has_init slightly clearer.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

SG!

@awelc awelc force-pushed the aw/module-initializers branch from bebdfe4 to b79df47 Compare February 10, 2022 21:05
@awelc awelc force-pushed the aw/module-initializers branch from b79df47 to 1d9f823 Compare February 10, 2022 21:19
@awelc awelc requested a review from gdanezis February 10, 2022 22:27
@@ -54,6 +54,10 @@ pub enum WalletCommands {
/// ID of the gas object for gas payment, in 20 bytes Hex string
#[structopt(long)]
gas: ObjectID,

/// gas budget for running module initializers
#[structopt(default_value = "0")]
Copy link
Collaborator

Choose a reason for hiding this comment

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

Still wondering about this--do we have any tests that try publishing from the wallet? I'd expect them to fail unless they explicitly pass a nonzero gas budget, but perhaps I'm wrong on that...

Copy link
Contributor

@lxfind lxfind left a comment

Choose a reason for hiding this comment

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

I think we should add is_ok and is_err to ExecutionStatus to facilitate the checks.

let ctx_bytes = mutable_ref_values.pop().unwrap();
let updated_ctx: TxContext = bcs::from_bytes(ctx_bytes.as_slice()).unwrap();
if let Err(err) = ctx.update_state(updated_ctx) {
exec_failure!(gas::MIN_MOVE_CALL_GAS, err);
Copy link
Contributor

Choose a reason for hiding this comment

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

We shouldn't return MIN_MOVE_CALL_GAS here. We have already finished VM execution, and hence any failure afterwards should return gas that's already used, in this case, gas_used.
But instead of fixing this here, see my other comment regarding how we should deal with tx context update.

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 am going to put a quick fix here to avoid immediate problems.

// objects). We guard it with a flag to avoid
// serialization cost for non-publishing calls.
let ctx_bytes = mutable_ref_values.pop().unwrap();
let updated_ctx: TxContext = bcs::from_bytes(ctx_bytes.as_slice()).unwrap();
Copy link
Contributor

Choose a reason for hiding this comment

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

Instead of updating TxContext, it will be cleaner if you actually consume TxContext in the call, and return a new one. User code is unable to manipulate TxContext arbitrarily, so the one returned should always be valid.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@sblackshear suggested passing a mutable reference to the context, but I am not sure what the deeper motivation for that is.

Also, a solution with returning the context would involve changes on the Move repo side, wouldn't it? At this point we are re-using the calling functionality we already have and it does not support returning values. Unless you have some indirect way of returning in mind.

) {
Ok(ExecutionStatus::Success { gas_used }) => gas_used,
Ok(ExecutionStatus::Failure { gas_used, error }) => exec_failure!(gas_used, *error),
Err(err) => exec_failure!(gas::MIN_MOVE_CALL_GAS, err),
Copy link
Contributor

Choose a reason for hiding this comment

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

This should not be MIN_MOVE_CALL_GAS. We need to charge based on what has been consumed so far.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Will fix!

if current_gas_budget > gas_used {
current_gas_budget -= gas_used;
} else {
current_gas_budget = 0;
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 there is a bug here. If the last run doesn't have sufficient gas, this will pass. But it should fail.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Actually, it's even more subtle, I think :-) Initially simply had current_gas_budget -= gas_used there and @sblackshear pointed out that we may need an additional check (if that subtraction failed, we'd panic). Now that I think about it, though, if current_gas_budget < gas_used then the call itself would fail (and throw an error) due to insufficient gas and we would never get to this subtraction. Does it make sense?

Copy link
Contributor

Choose a reason for hiding this comment

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

I see your point. Basically gas_used should be <= current_gas_budget by construction. In that case, we should make it a debug_assert?

}

// wrap the modules in an object, write it to the store
let package_object = Object::new_package(modules, Authenticator::Address(sender), ctx.digest());
state_view.write_object(package_object);

Ok(ExecutionStatus::Success)
let gas_cost = gas::calculate_module_publish_cost(&module_bytes);
let mut total_gas_used = gas_cost;
Copy link
Contributor

Choose a reason for hiding this comment

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

Now that we provide gas_budget with a Move Publish Transaction. For clarify, should that budget cover the cost of publishing itself too?

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 think you are right, but it's more of a philosophical question. I will change it accordingly, but perhaps @sblackshear wants to weigh in.

Copy link
Contributor

Choose a reason for hiding this comment

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

Can you file an issue to decide on this?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done in #440

let function = match Function::new_from_name(module, INIT_FN_NAME) {
Some(v) => v,
None => return false,
};
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there a reason we make the check here, instead of adding a verifier to check that any function named init should follow this rule to avoid confusions?

}

// wrap the modules in an object, write it to the store
let package_object = Object::new_package(modules, Authenticator::Address(sender), ctx.digest());
state_view.write_object(package_object);

Ok(ExecutionStatus::Success)
let gas_cost = gas::calculate_module_publish_cost(&module_bytes);
let mut total_gas_used = gas_cost;
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you file an issue to decide on this?

if current_gas_budget > gas_used {
current_gas_budget -= gas_used;
} else {
current_gas_budget = 0;
Copy link
Contributor

Choose a reason for hiding this comment

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

I see your point. Basically gas_used should be <= current_gas_budget by construction. In that case, we should make it a debug_assert?

Comment on lines +298 to +299
let no_init_calls = modules_to_init.is_empty();
if !no_init_calls {
Copy link
Contributor

Choose a reason for hiding this comment

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

These two lines don't seem necessary.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

no_init_calls is used in the code below.

Copy link
Contributor

Choose a reason for hiding this comment

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

hmmm interesting.
I think there is another bug here, imagine the following scenario:

  1. There are init calls, and the gas_budget is sufficient to cover the init calls
  2. But the gas_object doesn't have enough balance to cover total gas

In this case, we will eventually return Err with gas_used = gas_budget, which is wrong.

I think we really should have gas_budget to cover publish cost, it will make the logic a lot easier to reason about.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Got it! Before we make a decision on the gas_budget covering publish costs, a failure to deduct gas should result in total_gas_used being returned.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In fact, we should probably charge total_gas_used on both Move call and Move publish paths, as in both cases the actual work on the MoveVM side is already done.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

for the move call order, we also check on the budget to make sure the gas object has efficient balance

Was this check actually implemented?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Never mind. I can now see it's checked by the authority in check_gas_requirement.

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 include publish cost in the budget. I am fairly confident that's the right direction.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sounds good. It would also simplify things if we had just one constant instead of these two: MIN_MOVE_PUBLISH_GAS and MIN_MOVE_CALL_GAS. Since at the point of gas amount verification in check_gas_requirement we don't know if publish operation will involve calls, we should probably conservatively assume that they would and check against MIN_MOVE_CALL_GAS and charge MIN_MOVE_CALL_GAS upon publishing failure, making MIN_MOVE_PUBLISH_GAS kind of redundant (they have currently the same value anyway).

What do you think? Perhaps MIN_MOVE_GENERIC_GAS

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah that's a good idea. Or just MIN_MOVE_GAS.

awelc added a commit that referenced this pull request Feb 14, 2022
#436)

* Added assertions representing current set of restrictions for accessing temporary stores

* Gas-related fixes to the module initializers implementation

* Do not apply gas budget to the actual publishing operation for now

* Changes to assertions

* Gas-related cleanup and fixes

* Cleaned up gas handling during MoveVM operations

* Replaced a macro with a function
@awelc awelc linked an issue Feb 17, 2022 that may be closed by this pull request
mwtian pushed a commit that referenced this pull request Sep 12, 2022
* Final Demo

Co-authored-by: Anastasios Kichidis <akihidis@gmail.com>

* linter changes

* fix python linter errors

* fix: change cosmetic details

- print which validators we connect to,
- rust details of the last function,
- mark the display code more clearly

* sanitize seeder port input

Co-authored-by: Anastasios Kichidis <akihidis@gmail.com>
Co-authored-by: François Garillot <francois@garillot.net>
mwtian pushed a commit to mwtian/sui that referenced this pull request Sep 29, 2022
* Final Demo

Co-authored-by: Anastasios Kichidis <akihidis@gmail.com>

* linter changes

* fix python linter errors

* fix: change cosmetic details

- print which validators we connect to,
- rust details of the last function,
- mark the display code more clearly

* sanitize seeder port input

Co-authored-by: Anastasios Kichidis <akihidis@gmail.com>
Co-authored-by: François Garillot <francois@garillot.net>
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.

[fastx adapter/verifier] implement Move module initializers
3 participants