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

Call init() during deploy(), just once #543

Merged
merged 9 commits into from
Nov 14, 2022
Merged

Call init() during deploy(), just once #543

merged 9 commits into from
Nov 14, 2022

Conversation

mitschabaude
Copy link
Collaborator

@mitschabaude mitschabaude commented Nov 10, 2022

init() RFC

This proposes a simple, incremental change which is intended to

  • solve the pain point of ensuring that state is initialized only once
  • give users an opt-in, easy-to-use way of showing that their state was entirely set by proofs
  • give users a default where they can re-init the state multiple times

(with users I mean zkApp developers)

The logic that I propose is as follows:

  • there is a new method on the base SmartContract: init(). It is, by default, not a @method
  • inside deploy, we check whether the zkApp account either does not exist yet, or doesn't have a verification key on it (indicating first-time deployment)
  • if this is the case, deploy calls init.
  • inside the base init, we set the entire state to 0. we also set a precondition on provedState to be false.

The init method can be overridden by the user, to define custom state initialization logic. It's suitable for that because init will only run on the first deploy, and not on subsequent deploys. A typical smart contract could look like this:

class MyContract extends SmartContract {
  @state(Field) x = State<Field>();
  
  init() {
    super.init();
    this.setPermissions(...); // custom permissions
    this.x.set(Field(1));
  }
}

Note that this contract doesn't have a custom deploy method. There's no need for it, because we can set custom permissions in init as well (there's no reason to set their value again in re-deployments). I expect this to be the default pattern used for smart contracts going forward: no deploy, but a custom init method.

With the defaults, init is not a @method, and doesn't create a proof. Not being a method also has the side effect that init will not create its own account update, but will reuse the deploy account update. This is actually very important! Because, by default, zkApps have the editState permission set to proof. That means that init, without a proof, could not change the state if it were a separate account update after deploy.

Another nice side effect of having both in one update is that the user could still have state initialization logic in deploy. This pattern has been used in many examples so we can't expect it to immediately go away:

  deploy(args: deployArgs) {
    super.deploy(args);
    this.setPermissions(...); // custom permissions
    this.x.set(Field(1));
  }

Note that here, the base init is called during super.deploy(). Luckily, the usual pattern when overriding deploy is to put your logic after super.deploy(). This means that your changes to the state will come after init (which here sets the state to zero; if it came after, it would wipe out your initialization). This is why I don't expect major disruption from this change for users who continue to use the old pattern and don't override init.

Some users will want to use init() slightly differently and make it a method:

@method init() {
    super.init();
    this.x.set(Field(1));
  }

Because methods always create their own account update (otherwise they couldn't create a proof about it), the effect of the decorator is that there is now a second account update, separate from deploy, which just exercises the init logic. It will have a proof created for it, and after running this, the provedState attribute on the account is true.

How do we ensure that malicious end users don't use init to wipe out the state?

If there is no @method decorator, normal users won't be able to run init because state updates without proof or signature will not be allowed to them.

If there is a method decorator, then the provedState attribute will become true after the first deployment. In this state, nobody can run init, because of the precondition it has that provedState = false.

Minor qualification: Since provedState is not available yet from graphQL, as a temp solution I actually don't set that precondition. (It's commented out.) The temp solution is that init takes an optional privateKey argument, and if this argument is provided, the base init checks that this private key belongs to the zkApp public key. Therefore, a developer can easily ensure, already with this PR, that no-one else can run init, by passing the private key to the base init. (See the simple_zkapp.ts example in this PR. It's fine to ignore this paragraph, because there's already a PR up for exposing provedState on graphQL.

Can a developer change their mind and init the state a second time, with different state?

It depends: If the editState permission is set to proof, then the state can't be changed after deployment. However, with our current defaults, the permission itself can be rolled back, and so can the state. The same is true if a developer is using a @method for init. In fact, the question of when a developer can change the state later has nothing to do with init.

What if we forget to call super?

If we don't call super.init(), then the base logic which sets the whole state to zero doesn't run. This is not ideal, because in the proving case, we won't get provedState=true. This is why I implemented a warning: After init was run, deploy will look if the entire state was updated, and if not, print the following:

WARNING: the `init()` method was called without overwriting the entire state. This means that your zkApp will lack
the `provedState === true` status which certifies that the current state was verifiably produced by proofs (and not arbitrarily set by the zkApp developer).
To make sure the entire state is reset, consider adding this line to the beginning of your `init()` method:
super.init();

Shouldn't init() be a @method, and make a proof, by default?

I think it should eventually, but having it opt-in is fine as an incremental step. Note that, when the base init is a @method, then the state is silently wiped after deploy() even for developers who aren't aware of init and are still using the old pattern of initializing state in deploy. Therefore, it feels better to ramp up developers slowly on init so that it becomes wide-spread, before we make such a change which makes the old pattern obsolete.

@mitschabaude mitschabaude changed the title Call init during deploy, just once Call init() during deploy(), just once Nov 10, 2022
src/lib/zkapp.ts Outdated
initUpdate.update.appState.some(({ isSome }) => !isSome.toBoolean())
) {
console.warn(`WARNING: the \`init()\` method was called without overwriting the entire state. This means that your zkApp will lack
the \`isProved\` status which certifies that the current state was verifiably produced by proofs (and not arbitrarily set by the zkApp developer).
Copy link
Contributor

@MartinMinkov MartinMinkov Nov 10, 2022

Choose a reason for hiding this comment

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

I wonder if there's a link we can point to for more information about isProved. If this resource exists, it would be great to include it here too imo :D

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Good idea! Btw I realized its actually called provedState

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Well, not sure if there's a resource right now O_o

src/lib/zkapp.ts Outdated
@@ -178,6 +179,7 @@ function wrapMethod(
accountUpdates: [],
fetchMode: inProver() ? 'cached' : 'test',
isFinalRunOutsideCircuit: false,
numberOfRuns: Infinity,
Copy link
Contributor

Choose a reason for hiding this comment

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

Why do we use Infinity inside a checked computation here?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

err.. I introduced this numberOfRuns field on the transaction because the transaction code is run twice, and I wanted to identify whether I was in the first one, so that I wouldn't display the same warning twice. However, then in prove / compile I had no good idea how to set it; I just wanted it to be more than 0. So I opted for Infinity. Maybe 2 would also be fine

Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe we can introduce a type to have the constraints that it can only be the value 1, 2 or an ignore value. That way it's easier to tell what numberOfRuns should be at any point in time. Up to you though!

Copy link
Member

Choose a reason for hiding this comment

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

agreed, a 1 | 2 | 'ignore' or something like slightly more structured could be nice here

@jasongitmail
Copy link
Contributor

The logic that I propose is as follows:

  • there is a new method on the base SmartContract: init(). It is, by default, not a @method
  • inside deploy, we check whether the zkApp account either does not exist yet, or doesn't have a verification key on it (indicating first-time deployment)
  • if this is the case, deploy calls init.
  • inside the base init, we set the entire state to 0. we also set a precondition on provedState to be false.

+1 sounds great

Shouldn't init() be a @method, and make a proof, by default?

I think it should eventually, but having it opt-in is fine as an incremental step.

But this would change the behavior away from our discussed goals, no? It seems that the solution described earlier in this RFC, as opposed to in this last paragraph, is more ideal and in line with the user experience we discussed.

@mitschabaude
Copy link
Collaborator Author

But this would change the behavior away from our discussed goals, no?

The motivation would be to fulfill the original goal for introducing init() -- namely, that there is a default where the initial state is fully created by a proof, giving us the provedState status, without extra config work by the user. I think this will be quite a worthy goal if block explorers / wallet display provedState; in many zkApps this could be an important security guarantee.

I don't think this would harm any of our other goals, besides

  • the temporary issue of breaking the existing examples that set state by overriding deploy
  • the minor annoyance that there's an additional proof created during zk deploy. Although I think as a network which bets on client-side proving, it's fine to have developers do some proving, if the M1 thing is fixed.

Other than that, @jasongitmail is there anything else you don't like about init being a method by default? (I'm sure we'll still have a way to change that default)

Copy link
Member

@bkase bkase left a comment

Choose a reason for hiding this comment

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

This is very well-thought out and looks fantastic to me. I wonder if we can rip most of this description directly into our docs when this lands.

src/lib/zkapp.ts Outdated
@@ -178,6 +179,7 @@ function wrapMethod(
accountUpdates: [],
fetchMode: inProver() ? 'cached' : 'test',
isFinalRunOutsideCircuit: false,
numberOfRuns: Infinity,
Copy link
Member

Choose a reason for hiding this comment

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

agreed, a 1 | 2 | 'ignore' or something like slightly more structured could be nice here

@jasongitmail
Copy link
Contributor

jasongitmail commented Nov 10, 2022

@mitschabaude (re: the last paragraph of the RFC)
One aspect of concern, is that we discussed allowing devs to reinitialize their state, as long as a user tx hasn't been received yet. Otherwise, we leave devs with only two options for re-initializing state during their development that I see: 1.) redeploy to a new address, 2.) write a smart contract that will form a tx to update all state fields as desired. Others? These first sounds like a pain for the developer and the second seems like a non-ideal dev experience.

TLDR I'm +1 on the RFC, looks great and includes the goal behavior, but highlighting that the last paragraph would be a totally different DX.

So I'm -1 for accepting the last paragraph as something we will plan to do without further discussion, bc it's a totally different DX than we discussed as the goal, and with further discussion we could come up with other alternatives like allowing the dev to sepcify a flag during deployment if they know they want isProved set: zk deploy --isproved, rather than always setting isProved immediately. Allowing them to opt in, instead of imposing it upon everyone.

@mitschabaude
Copy link
Collaborator Author

@jasongitmail gotcha, we will discuss this further, but I want to point out that making init a method actually has no influence on the ability to reinitialize, since usually the smart contract wouldn't allow updating the state without proof anyway, after deployment

@mitschabaude
Copy link
Collaborator Author

The solution to this that I highlighted above is rolling back the permissions and the state, which means writing a custom transaction anyway. So it turns out this question is kind of orthogonal to the defaults for init

@mitschabaude mitschabaude merged commit cbf3271 into main Nov 14, 2022
@mitschabaude mitschabaude deleted the feature/init branch November 14, 2022 16:13
@mitschabaude mitschabaude added the rfc Issues that contain some important documentation label Nov 18, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
rfc Issues that contain some important documentation
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants