-
Notifications
You must be signed in to change notification settings - Fork 11.3k
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
Conversation
@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 |
Good idea. I was wondering about it myself, and based on your suggestion renamed it to |
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 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, | ||
}; |
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 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.
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.
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!
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.
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?
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.
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.
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.
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
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.
@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!).
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.
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)
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.
Done in #438.
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 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.
fastpay/src/client.rs
Outdated
@@ -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")] |
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 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?
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.
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) { |
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 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).
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.
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 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.
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 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.
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.
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:
- An object can be only in either
deleted
or inwritten
at any given time. - 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.
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.
Based on the current need (i.e. module initializers), I think we only have:
- Read after write is OK
- Nothing else is permitted (add assertions for read after delete, or delete after write, or write after delete)
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.
SG
authority_clients: HashMap<AuthorityName, LocalAuthorityClient>, | ||
committee: Committee, | ||
) -> ClientState<LocalAuthorityClient> { | ||
let (admin, admin_key) = get_key_pair_from_bytes(&[ |
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 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.
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.
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(); |
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.
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 doctx.objects_created += num_objects_created
.
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.
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.
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 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? |
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.
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?
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.
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 |
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.
// try objects creted in temp memory | |
// try objects created in temp memory |
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.
Done, thank you!
|
||
const INIT_FN_NAME: &IdentStr = ident_str!("init"); | ||
|
||
pub fn module_with_init(module: &CompiledModule) -> bool { |
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: I would find something like module_has_init
slightly clearer.
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.
SG!
bebdfe4
to
b79df47
Compare
…at no Rust code is involved
… module initializers
b79df47
to
1d9f823
Compare
…entionally) fail due to insufficient gas
@@ -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")] |
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.
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...
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 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); |
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.
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.
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 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(); |
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.
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.
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.
@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), |
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 should not be MIN_MOVE_CALL_GAS
. We need to charge based on what has been consumed so far.
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.
Will fix!
if current_gas_budget > gas_used { | ||
current_gas_budget -= gas_used; | ||
} else { | ||
current_gas_budget = 0; |
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 there is a bug here. If the last run doesn't have sufficient gas, this will pass. But it should fail.
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.
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?
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 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; |
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.
Now that we provide gas_budget
with a Move Publish Transaction. For clarify, should that budget cover the cost of publishing itself too?
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 you are right, but it's more of a philosophical question. I will change it accordingly, but perhaps @sblackshear wants to weigh in.
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.
Can you file an issue to decide on this?
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.
Done in #440
let function = match Function::new_from_name(module, INIT_FN_NAME) { | ||
Some(v) => v, | ||
None => return 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.
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; |
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.
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; |
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 see your point. Basically gas_used
should be <= current_gas_budget
by construction. In that case, we should make it a debug_assert
?
let no_init_calls = modules_to_init.is_empty(); | ||
if !no_init_calls { |
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.
These two lines don't seem necessary.
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.
no_init_calls
is used in the code below.
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.
hmmm interesting.
I think there is another bug here, imagine the following scenario:
- There are init calls, and the gas_budget is sufficient to cover the init calls
- 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.
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.
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.
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.
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.
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.
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?
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.
Never mind. I can now see it's checked by the authority in check_gas_requirement
.
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 include publish cost in the budget. I am fairly confident that's the right direction.
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.
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
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.
Yeah that's a good idea. Or just MIN_MOVE_GAS
.
#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
* 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>
* 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>
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.