Please note that zkApp programmability is not yet available on Mina Mainnet, but zkApps can now be deployed to Berkeley Testnet.
How to Write a zkApp
A zkApp consists of a smart contract and a UI to interact with it. First, we’ll install the Mina zkApp CLI and write a smart contract.
Write a smart contract
Your smart contract will be written using Mina zkApp CLI.
Mina zkApp CLI makes it easy to follow recommended best practices by providing project scaffolding including dependencies such as SnarkyJS, a test framework (Jest), code auto-formatting (Prettier), linting (ES Lint), & more.
Install Mina zkApp CLI
npm install -g zkapp-cli
Dependencies:
- NodeJS 16+ (or 14 using
node --experimental-wasm-threads
) - NPM 6+
- Git 2+
If you have an older version installed, we suggest installing a newer version using the package manager for your system: Homebrew (Mac), Chocolatey (Windows), or apt/yum/etc (Linux). On Linux, you may need to install a recent NodeJS version via NodeSource (deb or rpm), as recommended by the NodeJS Project.
Start a project
Now that you have Mina zkApp CLI installed, you can start with an example or start your own project.
Option A: Start with an example (recommended)
Examples are based on the standard project structure, but with additional files in the /src
directory as the only difference.
- Install: Run
zk example sudoku
. This creates a new project and includes the example files (i.e. the smart contract) inside the project’ssrc/
directory. Typels
& hit enter to see the files that were created or open the directory in a code editor such as VS Code. - Run tests: Run
npm run test
. Tests are written using Jest. After running this command, you should see all tests pass. You can also runnpm run testw
to run tests in watch mode, so it will automatically re-run tests when saving changes to your code. - Build the example: Run
npm run build
. This will compile your TypeScript into JavaScript inside the project’s/build
directory. - Deploy to Testnet: Run
zk config
, which will walk you through adding a network alias to your project’sconfig.json
. For Berkeley Testnet, we recommend usingberkeley
as the name,0.1
for the fee, andhttps://proxy.berkeley.minaexplorer.com/graphql
for the url. Then runzk deploy
and follow the prompts. See the how to deploy a zkApp page for further details.
You can view a list of all available examples here.
Option B: Start your own project
- Install: Run
zk project <myproj>
. Typels
and hit enter to see the newly created project structure. - Run tests: Run
npm run test
. Tests are written using Jest. After running this command, you should see all tests pass. You can also runnpm run testw
to run tests in watch mode, so it will automatically re-run tests when saving changes to your code. - Build: Run
npm run build
. This will compile your TypeScript code into JavaScript inside the project’s/build
. - Deploy to Testnet: Run
zk config
, which will walk you through adding a network alias to your project’sconfig.json
. For Berkeley Testnet, we recommend usingberkeley
as the name,0.1
for the fee, andhttps://proxy.berkeley.minaexplorer.com/graphql
for the url. Then runzk deploy
and follow the prompts. See the how to deploy a zkApp page for further details. - Deploy to Mainnet: (Coming soon.)
Writing your smart contract
The goal of this section is to explain the concepts that you will need to understand to write a zero-knowledge-based smart contract.
If you haven’t yet read the how zkApps work pages, please read it first so that this section makes sense.
SnarkyJS
zkApps are written in TypeScript using SnarkyJS. SnarkyJS is a TypeScript library for writing smart contracts based on zero-knowledge proofs for the Mina Protocol. It is included automatically when creating a new project using the Mina zkApp CLI.
To view the full SnarkyJS reference, please see the snarkyJS reference.
Concepts
Field elements are the basic unit of data in zero-knowledge proof programming. Each field element can store a number up to almost 256 bits in size. You can think of it as a uint256 in Solidity.
For the cryptography inclined, the exact max value that a field can store is: 28,948,022,309,329,048,855,892,746,252,171,976,963,363,056,481,941,560,715,954,676,764,349,967,630,336
For example, in typical programming, you might use:
const sum = 1 + 3
.
In SnarkyJS, you would write this as:
const sum = new Field(1).add(new Field(3))
This can be simplified as:
const sum = new Field(1).add(3)
Note that the 3 is auto-promoted to a field type to make this cleaner.
Primitive data types
A couple common data types you may use are:
new Bool(x); // accepts true or false
new Field(x); // accepts an integer, or a numeric string if you want to represent a number greater than JavaScript can represent but within the max value that a field can store.
new UInt64(x); // accepts a Field - useful for constraining numbers to 64 bits
new UInt32(x); // accepts a Field - useful for constraining numbers to 32 bits
PrivateKey, PublicKey, Signature; // useful for accounts and signing
new Group(x, y); // a point on our elliptic curve, accepts two Fields/numbers/strings
Scalar; // the corresponding scalar field (different than Field)
CircuitString.from('some string'); // string of max length 128
In the case of Field
and Bool
, you can also call the constructor without new
:
let x = Field(10);
let b = Bool(true);
Conditionals
Traditional conditional statements are not yet supported by SnarkyJS:
// this will NOT work
if (foo) {
x.assertEquals(y);
}
Instead, use SnarkyJS’ built-in Circuit.if()
method, which is a ternary operator:
const x = Circuit.if(new Bool(foo), a, b); // behaves like `foo ? a : b`
Functions
Functions work as you would expect in TypeScript. For example:
function addOneAndDouble(x: Field): Field {
return x.add(1).mul(2);
}
Common methods
Some common methods you will use often are:
let x = new Field(4); // x = 4
x = x.add(3); // x = 7
x = x.sub(1); // x = 6
x = x.mul(3); // x = 18
x = x.div(2); // x = 9
x = x.square(); // x = 81
x = x.sqrt(); // x = 9
let b = x.equals(8); // b = Bool(false)
b = x.greaterThan(8); // b = Bool(true)
b = b.not().or(b).and(b); // b = Bool(true)
b.toBoolean(); // true
let hash = Poseidon.hash([x]); // takes array of Fields, returns Field
let privKey = PrivateKey.random(); // create a private key
let pubKey = PublicKey.fromPrivateKey(privKey); // derive public key
let msg = [hash];
let sig = Signature.create(privKey, msg); // sign a message
sig.verify(pubKey, msg); // Bool(true)
For a full list, see the SnarkyJS reference.
Smart Contract
Now that we have covered the basics of writing SnarkyJS programs, let's see how to create a smart contract.
Smart contracts are written by extending the base class SmartContract
:
class HelloWorld extends SmartContract {}
The constructor
of a SmartContract
is inherited from the base class and should not be overriden.
It takes the zkApp account address (a public key) as its only argument:
let zkAppKey = PrivateKey.random();
let zkAppAddress = PublicKey.fromPrivateKey(zkAppKey);
let zkApp = new HelloWorld(zkAppAddress);
Later, we show you how you can deploy a smart contract to an on-chain account.
On Mina, there is no strong distinction between normal "user accounts" and "zkApp accounts". A zkApp account is just a normal account that has a smart contract deployed to it – which essentially just means there's a verification key stored on the account, which can verify zero-knowledge proofs generated with the smart contract.
Methods
Interaction with a smart contract happens by calling one or more of its methods. You declare methods using the @method
decorator:
class HelloWorld extends SmartContract {
@method myMethod(x: Field) {
x.mul(2).assertEquals(5);
}
}
Within a method, you can use SnarkyJS' data types and methods to define your custom logic.
Later, we'll show how you can...
- run a method (off-chain)
- create a proof that it executed successfully
- send that proof to the Mina network, to trigger actions like a state change or payment
To get an idea what "successful execution" means, look at this line in our example above:
x.mul(2).assertEquals(5);
Creating a proof for this method will only be possible if the input x
satisfies the equation x * 2 === 5
. This is what we call a "constraint".
Magically, the proof can be checked without seeing x
– it's a private input.
The method above is not very meaningful yet. To make it more interesting, we need a way to interact with accounts, and record state on-chain. Check out the next section for more on that!
One more note about private inputs: The method above has one input parameter, x
of type Field
. In general, arguments can be any of the built-in SnarkyJS type that you saw: Bool
, UInt64
, PrivateKey
, etc. From now on, we will refer to those types as structs`.
Under the hood, every @method
defines a zk-SNARK circuit. From the cryptography standpoint, a smart contract is a collection of circuits, all of which are compiled into a single prover & verification key. The proof says something to the effect of "I ran one of these methods, with some private input, and it produced this particular set of account updates". In ZKP terms, the account updates are the public input. The proof will only be accepted on the network if it verifies against the verification key stored in the account. This ensures that indeed, the same code that the zkApp developer wrote also ran on the user's device – thus, the account updates conform to the smart contract's rules.
You will find that inside a @method
, things sometimes behave a little differently. For example, the following code can't be used in a method where x: Field
is an input parameter:
console.log(x.toString()); // don't do this inside a `@method`! 😬
This doesn't work because, when we compile the SmartContract into prover and verification keys, we will run your method in an environment where the method inputs don't have any concrete values attached to them. They are like mathematical variables x
, y
, z
which are used to build up abstract computations like x^2 + y^2
, just by running your method code.
Therefore, when executing your code and trying to read the value of x
to turn it into a string via x.toString()
, it will blow up because such a value can't be found. On the other hand, during proof generation all the variables have actual values attached to them (cryptographers call them "witnesses"); and it makes perfect sense to want to log these values for debugging.
This is why we have a special function for logging stuff from inside your method:
Circuit.log(x);
The API is like that of console.log
, but it will automatically handle printing SnarkyJS data types in a nice format. During SmartContract compilation, it will simply do nothing.
On-chain state
A smart contract can contain on-chain state, which is declared as a property
on the class with the @state
decorator:
class HelloWorld extends SmartContract {
@state(Field) x = State<Field>();
// ...
}
Here, x
is of type Field
. Like with method inputs, only SnarkyJS structs can be used for state variables.
In the current design, the state can consist of at most 8 Fields of 32 bytes each. These states are stored on the zkApp account.
Some structs take up more than one Field: for example, a PublicKey
needs 2 of the 8 Fields.
States are initialized with the State()
function.
A method can modify on-chain state by using this.<state>.set()
:
class HelloWorld extends SmartContract {
@state(Field) x = State<Field>();
@method setX(x: Field) {
this.x.set(x);
}
}
As a zkApp developer, if you add this method to your smart contract, you are saying: "Anyone can call this method, to set x
on the account to any value they want."
Reading state
Often, we also want to read state – check out this example:
class HelloWorld extends SmartContract {
@state(Field) x = State<Field>();
@method increment() {
// read state
const x = this.x.get();
this.x.assertEquals(x);
// write state
this.x.set(x.add(1));
}
}
The increment()
method fetches the current on-chain state x
with this.x.get()
.
Later, it sets the new state to x + 1
using this.x.set()
. Simple!
There's another line though, which looks weird at first:
this.x.assertEquals(x);
To understand it, we have to take a step back, and understand what it means to "use an on-chain value" during off-chain execution.
For sure, when we use an on-chain value, we have to prove that this is the on-chain value. Verification has to fail if it's a different value! Otherwise, a malicious user could modify SnarkyJS and make it just use any other value than the current on-chain state – breaking our zkApp.
To prevent that, we link "x
at proving time" to be the same as "x
at verification time". We call this a precondition – a condition that is checked by the verifier (a Mina node) when it receives the proof in a transaction. This is what this.x.assertEquals(x)
does: it adds the precondition that this.x
– the on-chain state at verification time – has to equal x
– the value we fetched from the chain on the client-side. In zkSNARK language, x
becomes part of the public input.
Side note: this.<state>.assertEquals
is more flexible than equating with the current value. For example, this.x.assertEquals(10)
fixes the on-chain x
to the number 10
.
Why didn't we just make this.x.get()
add the precondition, automatically, so that you didn't have to write this.x.assertEquals(x)
?
Well, we like to keep things explicit. The assertion reminds us that we add logic which can make the proof fail: If x
isn't the same at verification time, the transaction will be rejected. So, reading on-chain values has to be done with care if many users are supposed to read and update state concurrently. It is applicable in some situations, but might cause races, and call for workarounds, in other situations.
One such workaround is the use of actions – see Actions and Reducer.
Assertions
Let's modify the increment()
method to accept a parameter:
class HelloWorld extends SmartContract {
@state(Field) x = State<Field>();
@method increment(xPlus1: Field) {
const x = this.x.get();
this.x.assertEquals(x);
x.add(1).assertEquals(xPlus1);
this.x.set(xPlus1);
}
}
Here, after obtaining the current state x
and asserting that it equals the on-chain value, we make another assertion:
x.add(1).assertEquals(xPlus1);
If the assertion fails, SnarkyJS will throw an error and not submit the transaction. On the other hand, if it succeeds, it becomes part of the proof that is verified on-chain.
Because of this, our new version of increment()
is guaranteed to behave like the previous version: It can only ever
update the state x
to x + 1
.
You can add optional failure messages to assertions, to make debugging easier. For example, the above example could be written as:
x.add(1).assertEquals(xPlus1, 'x + 1 should equal xPlus1');
Assertions can be incredibly useful to constrain state updates. Common assertions you may use are:
x.assertEquals(y); // x = y
x.assertBoolean(); // x = 0 or x = 1
x.assertLt(y); // x < y
x.assertLte(y); // x <= y
x.assertGt(y); // x > y
x.assertGte(y); // x >= y
For a full list, see the SnarkyJS reference.
Public and private inputs
We touched on this already, but it's good to cover it in more depth:
While the state of a zkApp is public, method parameters are private.
When a smart contract method is called, the proof it produces uses zero-knowledge to hide inputs and details of the computation.
The only way method parameters can be exposed is when the computation explicitly exposes them, as
in our last example where the input was directly stored in the public state: this.x.set(xPlus1);
Let's show an example where this is not the case, by defining a new method called incrementSecret()
:
class HelloWorld extends SmartContract {
@state(Field) x = State<Field>();
// ...
@method incrementSecret(secret: Field) {
const x = this.x.get();
this.x.assertEquals(x);
Poseidon.hash(secret).assertEquals(x);
this.x.set(Poseidon.hash(secret.add(1)));
}
}
This time, our input is called secret
. We check that the hash of our secret is equal to the current state x
.
If this is the case, we add 1 to the secret and set x
to the hash of that.
When running this successfully, it just proves that the code was run with some input secret
whose hash is x
,
and that the new x
will be set to hash(secret + 1)
.
However, the secret itself remains private, because it can't be deduced from its hash.
Initializing state
We didn't cover how to initialize on-chain state yet. This can be done in the init()
method.
Like the constructor, init()
is predefined on the base SmartContract
class.
It will be called when you deploy your zkApp with the zkApp CLI, for the first time. It won't be called if you upgrade your contract and deploy a second time.
You can override this method to add initialization of your on-chain state:
class HelloWorld extends SmartContract {
@state(Field) x = State<Field>();
init() {
super.init();
this.x.set(Field(10)); // initial state
}
}
Note that we have to call super.init()
, which sets your entire state to 0.
If you don't have any state to initialize to values other than 0, then there's no need to override init()
, you can just leave it out.
In the example above however, we are setting our state x
to Field(10)
.
Composing zkApps
A powerful feature of zkApps is that they are composable, just like Ethereum smart contracts. You can simply call smart contract methods from other smart contract methods:
class HelloWorld extends SmartContract {
@method myMethod(otherAddress: PublicKey) {
const calledContract = new OtherContract(otherAddress);
calledContract.otherMethod();
}
}
class OtherContract extends SmartContract {
@method otherMethod() {}
}
When a user calls HelloWorld.myMethod()
, SnarkyJS will create two separate proofs — one for the execution of myMethod()
as usual, and a separate one for the execution of OtherContract.otherMethod()
. The myMethod()
proof will compute an appropriate hash of the function signature of otherMethod()
plus any arguments and return values of that function call, and guarantee that this hash matches the callData
field on the account update produced by otherMethod()
, which is made part of myMethod()
's public input. Therefore, when calling another zkApp method, you effectively prove: "I called a method with this name, on this zkApp account, with this particular arguments and return value."
To ensure other methods can use a return value of your @method
, you must annotate the return value in your TypeScript function signature. Here's an example of returning a Bool
called isSuccess
:
@method otherMethod(): Bool { // annotated return type
// ...
return isSuccess;
}
Custom data types
As mentioned previously, smart contract method arguments can be any of the built-in SnarkyJS types.
However, what if you want to define your own data type?
You can create a custom data type for your smart contract using the Struct
function that SnarkyJS exposes. To do this, create a class that extends Struct({ })
.
Then, inside the object { }
, define the fields that you want to use in your custom data type.
For example, let us say you want to create a custom data type called Point
representing a 2D point on a grid. The Point
struct has no instance methods and is only used to hold information about the x
and y
points.
You can create a such a Point class by creating a new class that extends the Struct
class:
class Point extends Struct({
x: Field,
y: Field,
}) {}
Now that you have defined your Struct, you can use it in your smart contract for any SnarkyJS built-in types.
For example, the following smart contract uses the Point
Struct defined above as state and as a method argument:
export class Grid extends SmartContract {
@state(Point) p = State<Point>();
@method init() {
this.p.set(new Point({ x: Field(1), y: Field(2) }));
}
@method move(newPoint: Point) {
const point = this.p.get();
this.p.assertEquals(point);
const newX = point.x.add(newPoint.x);
const newY = point.y.add(newPoint.y);
this.p.set(new Point({ x: newX, y: newY }));
}
}
Note that your Structs can contain SnarkyJS built-in types like Field
, Bool
, UInt64
, etc or even other custom types that you've defined which are based on the Struct
class.
This allows for great composability and reusability of structs.
Transactions and account updates
Now that we've got an idea about writing zkApp methods, we need to discuss how users can call these methods. Recall that smart contracts execute off-chain. The result of such an off-chain execution is a transaction, which can be sent to the Mina network to apply the changes made by the smart contract. In this section we'll learn what a transaction looks like, and how you can create one.
The fundamental data structure that Mina transactions are built from is called an account update. An account update always contains updates to one specific on-chain account. For example, if you transfer MINA from one account to another, that will update the balance on two accounts – the sender and the receiver. Therefore, sending MINA requires two account updates. As you'll see later, account updates are a flexible and powerful data structure, which can express all kinds of updates, events and preconditions that you'll need for developing smart contracts.
A transaction is a JSON object of the form { feePayer, accountUpdates: [...], memo }
. Here, the feePayer
is a special account update of slightly simpler structure. In particular, it contains a fee
field which has to be used to specify the transaction fee. The accountUpdates
array, on the other hand, is a list of normal account updates, which make up the bulk of the transaction. Finally, memo
is an encoded string which can be used to attach an arbitrary short message – we'll ignore it for now.
We create transactions in SnarkyJS by calling Mina.transaction(...)
, which takes the sender (a public key) and a callback that contains your transaction's logic.
const sender = PublicKey.fromBase58('B62..'); // the user address
const zkapp = new MyContract(address); // MyContract is a SmartContract
const tx = await Mina.transaction(sender, () => {
zkapp.myMethod(someArgument);
});
In this example, the transaction consists of calling a single SmartContract
method, called myMethod
. You can inspect the transaction yourself by printing it out as JSON:
console.log(tx.toJSON());
If you try this, you'll see a massive JSON object with lots of fields, most of which are set to their default value. There's also a way to pretty-print transactions in a more human-readable, condensed format:
console.log(tx.toPretty());
Depending on the logic of myMethod()
, this could print something like the following:
[
{
publicKey: '..VeLh',
fee: '0',
nonce: '0',
authorization: '..EzRQ',
},
{
label: 'MyContract.myMethod()',
publicKey: '..Nq6w',
update: { appState: '["1",null,null,null,null,null,null,null]' },
preconditions: {
account: '{"state":["0",null,null,null,null,null,null,null]}',
},
authorizationKind: 'Proof',
authorization: undefined,
},
];
From this output, there are several important things we can learn about transactions.
First of all, this is an array with two entries: the account updates that make up this transaction. The first one is always the fee payer, whose public key we passed in as sender
. For the fee
, which we didn't specify, SnarkyJS filled in 0; the authorization
was filled with a dummy signature. In a user-facing zkApp, you typically wouldn't care about setting those values – instead, you'd create a transaction like this, in the browser, and pass it on to the user's wallet. The wallet would replace your fee payer with one that represents the user account, with the user's settings for the fee. It would also fill the authorization
field with a signature created from the user's private key. See our section about connecting your zkApp with a user's wallet.
The second account update in our list has a label: 'MyContract.myMethod()'
. This tells us that it corresponds to the method call we performed. A @method
call always results in the creation of an account update – namely, an update to the zkApp account itself. Let's see what the other fields in this account update mean:
publicKey
– the zkApp address (like other non-human-readable strings, this is truncated bytx.toPretty()
)update: { appState: [...] }
– shows how your method wants to update on-chain state, usingthis.<state>.set()
. The names and pretty types you defined using@state
are removed in this representation; instead, you see a raw list of 8 field elements, ornull
for state fields that aren't updated.preconditions: { account: { state: [...] } }
– similar to theupdate
, this has one entry per field of on-chain state. These are the preconditions that you created withthis.<state>.assertEquals()
. In this example, your transaction will only be accepted if the first of the 8 state fields equals 0. Thenull
values mean that there's no condition on the other 7 state fields.authorizationKind: 'Proof'
– this means that this account update needs to be authorized with a proof. This is the default when you call a zkApp method, but not necessarily for other account updates, as we'll explain below.authorization: undefined
– the proof that's needed on this update isn't there yet! We'll show how to add it in a minute.
Note that there a many more fields that account updates can have, but tx.toPretty()
only prints the ones that have actual content. Also, the ones above may be missing: For example, if our zkApp doesn't set any state, the update
field might be missing. In that case, strictly speaking it wouldn't always be an "update" in the sense that the account is modified. We still use the term "account update" for simplicity.
As you might have noticed, these account updates weren't created in a very explicit manner. Instead, SnarkyJS gives you an imperative API, with "commands" like state.set()
. Under the hood, these commands create and modify account updates in a transaction, like you saw above. In the end, the entire transaction will be sent to the network, as one atomic update. If something fails – for example, one of the account updates has insufficient authorization – the entire transaction is rejected and doesn't get applied. This is in contrast to an EVM contract, where the initial steps of a method call could succeed even if the method fails at a later step.
Creating proofs, and what they mean
Let's finally see how to create zero-knowledge proofs!
await MyContract.compile(); // this might take a while
// ...
const tx = await Mina.transaction(sender, () => {
zkapp.myMethod(someArgument);
});
await tx.prove(); // this might take a while
There are two new operations here:
MyContract.compile()
creates prover & verification keys from your smart contract.1 You need to do this before you can create any proofs!tx.prove()
goes through your transaction, and creates proofs for all the account updates that came from method calls.
Both of these are heavy cryptographic operations, and they might take between a few seconds and a few minutes, depending on the amount of logic you're proving and on how fast your machine is. If you print the transaction again with tx.toPretty()
, you'll find that it now has the proof as a base64 string inside the authorization
field:
[
// ...
{
label: 'MyContract.myMethod()',
// ...
authorization: { proof: '..KSkp' },
},
];
You might wonder: what, exactly, is proved here? How is the proof linked to the account update it is part of?
The proof attests to two different things:
- The execution of
myMethod()
- The public input of that execution
Recall that all method arguments are private inputs. So, the verifier doesn't get to see them, and the proof doesn't say anything about them (it only says that there were some private inputs that satisfied all constraints). However, a zk proof can also have a public input. In the case of zkApps, the public input is the account update. It is passed in implicitly when you do tx.prove()
. The prover function (i.e., your smart contract logic) creates its own account update and constrains it to equal the public input.
You can think of the public input as data that is shared between the prover and verifier. The verifier passes in the public input when verifying it, and the proof will only be valid if it was created with the same public input. For us, this means that this proof attests to the validity of exactly this account update. If you change the account update before sending it to the Mina network, the proof won't be valid for it anymore. In other words: The only valid account updates for a zkApp account are the ones created according to the logic of your SmartContract
. This is the core of why we can have smart contracts that execute on the client side.
Payments, and more on public inputs
Let's continue our discussion of account updates from the preceding sections, at the hand of an example that is important on its own: Paying out MINA from a zkApp. To send MINA, you can use this.send()
from your smart contract method:
class MyContract extends SmartContract {
@method payout(amount: UInt64) {
// TODO: logic that determines whether the user is allowed to claim this amount
this.send({ to: this.sender, amount });
}
}
This simple example @method payout()
can be called by anyone to send a given amount of nanoMINA to themselves. Note that we get the sender of the transaction with this.sender
. In a real zkApp, you'd probably want to add some conditions that are checked in this method, determining who can call it with which amounts. Let's call this method in a transaction, and print out the result:
const MINA = 1e9;
const tx = await Mina.transaction(sender, () => {
zkapp.payout(UInt64.from(5 * MINA));
});
await tx.prove();
console.log(tx.toPretty());
MINA amounts, in all SnarkyJS APIs and elsewhere in the protocol, are always denominated in nanoMINA = 10^(-9)
MINA. This is why we set const MINA = 1e9
.
What's interesting for us is that our transaction now has 3 account updates:
[
{
// fee payer
},
{
label: 'MyContract.payout()',
publicKey: '..Nq6w',
balanceChange: { magnitude: '5000000000', sgn: 'Negative' },
authorizationKind: 'Proof',
authorization: { proof: '..KSkp' },
},
{
publicKey: '..VeLh',
balanceChange: { magnitude: '5000000000', sgn: 'Positive' },
callDepth: 1,
caller: '..umxw',
authorizationKind: 'None_given',
},
];
The zkApp update with label 'MyContract.payout()'
has a negative balanceChange
of 5 billion (= 5 MINA). This makes sense, because we are sending MINA away from the zkApp account.
Then, there's an additional account update, with a corresponding positive balance change – the user account that receives MINA.
Two quick observations:
- We didn't create the receiver account update explicitly. It was created, and attached to the transaction, by calling
this.send()
. You'll find that SnarkyJS tries to abstract away the low-level language of account updates where possible, and to give you intuitive commands to create the right ones. However, you'll sometimes also create account updates explicitly (see next section). - The user update has
authorizationKind: 'None_given'
. That means it's not authorized. This is possible because it doesn't include any changes that require authorization: It just receives MINA, and you're able to send someone MINA without their permission.
In general, there are three kinds of authorization that an account update can have: a proof, a signature, or none. We'll learn about signatures in the next section.
Next, we observe that the user account update has a callDepth: 1
and a non-default caller
field. We won't explain this in detail, but it has to do with the fact that it was created from within a zkApp call. Account updates, even though displayed as a flat list here, are implicitly structured as a list of trees. Updates with a call depth of 1 or higher are child nodes of another update in that list of trees. In our case, the zkApp (sender) account update is at the top level (callDepth: 0
) and the user (receiver) account update is a child of it.
So, what is the meaning of this tree structure? Recall that in the last section, we explained how the zkApp account update is public input to its proof. Now, the fully general version of that statement is: In a tree of account updates, all nodes are public inputs to the proof of the root node. (If there is such a proof. This also holds for sub-trees of each tree.)
Concretely, in our example, both the zkApp account update and the user account update are public input to the zkApp method call. Intuitively, being public input means that the zkApp can "see" and constrain the update as part of its proof. Here, it means that nobody could change the public key of the receiver, or amount they receive, without making the proof invalid. The update can only contain what the method specified.
All of this is true because this.send()
, under the hood, placed the receiver update at call depth 1, under the zkApp update. As a counter-example: The fee payer is never part of the public input. It can be anything without affecting the validity of the proof.
A key takeaway is: If you want something to become part of your proof, you have to put it inside your @method
.
Signing transactions, and explicit account updates
Let's recap: We have explained how to write a SmartContract. We've seen how to create a transaction which calls that contract, and how the transaction consists of account updates which were created by SnarkyJS under the hood. Now, we'll see an example of creating an account update explicitly. We'll also learn how to use signatures, for authorizing updates to user accounts.
We continue the payment topic of last section, where we paid out MINA from a zkApp. This time, we go the other direction: make a deposit from the user into the zkApp. Payments made from a user account will require a signature by the user. Here's the smart contract code:
class MyContract extends SmartContract {
@method deposit(amount: UInt64) {
let senderUpdate = AccountUpdate.create(this.sender);
senderUpdate.requireSignature();
senderUpdate.send({ to: this, amount });
// TODO: logic that gives the user something in return for the deposit
}
}
Let's unpack what happens here. The first line of our method creates a new, empty account update for the sender account:
let senderUpdate = AccountUpdate.create(this.sender);
AccountUpdate
is the class in SnarkyJS that represents account udpates. AccountUpdate.create()
not only instantiates this class, but also attaches the update to the current transaction, at the same level where create
is called. If it is called inside a @method
, the AccountUpdate
is created as a child (public input) of the zkApp update.
The next line tells SnarkyJS that this update will be authorized with a signature:
senderUpdate.requireSignature();
We'll get into this later. We also could've used a shortcut which does both AccountUpdate.create()
and requireSignature()
in one command:
let senderUpdate = AccountUpdate.createSigned(this.sender); // create + requireSignature
Finally, we use .send()
on the sender AccountUpdate
to deposit into the zkApp, which has the same API as this.send()
:
senderUpdate.send({ to: this, amount });
Note that instead of an address as the to
field, we pass in this
, which is a SmartContract
. This is done so that .send()
doesn't create an additional update, but uses the one that's already created for our zkApp.
If we create a transaction for calling this method like before, it looks like this:
[
{
// fee payer
},
{
label: 'MyContract.deposit()',
balanceChange: { magnitude: '5000000000', sgn: 'Positive' },
// ...
},
{
publicKey: '..VeLh',
balanceChange: { magnitude: '5000000000', sgn: 'Negative' },
callDepth: 1,
useFullCommitment: true,
caller: '..umxw',
authorizationKind: 'Signature',
authorization: undefined,
},
];
The third account update is the one we created with AccountUpdate.create()
. Two changes to it were caused by calling requireSignature()
:
useFullCommitment: true
, which we won't explain here but has to do with replay protection if you're using signatures.authorizationKind: 'Signature'
Finally, authorization: undefined
indicates that we didn't provide the signature yet.
In a user-facing zkApp, user signatures will typically be added by a wallet, not within SnarkyJS. In that case, the missing signature is fine. However, for testing and calling zkApps via node, you need to add the signatures yourself. The command to do that is tx.sign([...privateKeys])
, called after Mina.transaction
on the finished transaction. Here's an example:
const sender = senderPrivateKey.toPublicKey(); // public key from sender's private key
const tx = await Mina.transaction(sender, () => {
zkapp.deposit(UInt64.from(5 * MINA));
});
await tx.prove();
tx.sign([senderPrivateKey]); // senderKey is a PrivateKey
The example first shows us how we can derive the sender's public key sender
from its private key senderPrivateKey
.
Note that .sign()
takes an array, so you could provide multiple private keys for signing. .sign()
will go through the transaction and add signatures on all account updates which 1) need a signature and 2) whose public key matches one of the private keys that were provided. In the example above, two account updates are signed with tx.sign()
: The fee payer and the depositor account update. Both have the sender
public key on them, which matches senderPrivateKey.toPublicKey()
.
SnarkyJS allows you to load and store private and public keys in base58 format. Here's how the sender private key might be created in a script:
const senderPrivateKey = PrivateKey.fromBase58('EKEQc95...');
In a real server-side deployment, you probably want to load keys from a file or environment variable, instead of hard-coding them in your source code.
To summarize, there are three types of authorization that account updates can have, which are typically used in different circumstances:
- Proof authorization – used for zkApp accounts when you do a
@method
call. Proofs are verified against the on-chain verification key. - Signature authorization – used to update user accounts. Signatures are verified against the account's public key.
- No authorization – used on updates which don't require authorization, for example positive balance changes.
The list above just reflects common defaults. The full source of truth is given by the account permissions, see Permissions. Using permissions, account owners can decide on a fine-grained level which type of authorization is required on which kinds of updates.
Sending transactions
The final step of creating a transaction is sending it to the network. Like signing, in a user-facing zkApp this is usually handled by a wallet. But you need to know how to do it yourself for testing and scripting.
To send a transaction, we need to specify what network we're interacting with. This is done by specifying a "Mina instance" at the beginning of your script:
const Network = Mina.Network('https://example.com/graphql');
Mina.setActiveInstance(Network);
The network URL has to be a GraphQL endpoint which exposes a compatible GraphQL API. This URL will not only determine where transactions are sent, but also where SnarkyJS gets account information from, when creating transactions. For example, when you do something like this.<state>.get()
in your smart contract, the Mina instance is asked for the account using Mina.getAccount
, which in turn will cause the account to be fetched from the GraphQL endpoint.
To send a transaction, create it as before and then use tx.send()
:
// set Mina instance
const Network = Mina.Network('https://example.com/graphql');
Mina.setActiveInstance(Network);
// create the transaction, add proofs and signatures
const tx = await Mina.transaction(sender, () => {
// ...
});
await tx.prove();
tx.sign([senderPrivateKey]);
// send transaction
await tx.send();
The output of tx.send()
can be used to wait for inclusion of this transaction in a block, and to get the transaction hash (which lets you look up the pending transaction on a block explorer):
// send transaction, log transaction hash
let pendingTx = await tx.send();
console.log(`Got pending transaction with hash ${pendingTx.hash()}`);
// wait until transaction is included in a block
await pendingTx.wait();
// our account updates are applied on chain!
Apart from Mina.Metwork
, you can also use a mocked "Mina instance" for local testing:
const Local = Mina.LocalBlockchain();
Mina.setActiveInstance(Local);
Doing this means setting up a fresh, local ledger, which is pre-filled with a couple of accounts with funds on them that you have access to. "Sending" a transaction here just means applying your account updates to that local Mina instance. This is helpful for testing, especially because account updates go through the same validation logic locally that they would on-chain.2
You can learn more about testing in How to test your zkApp.
Next Steps
Now that you've learned how to write and operate a basic smart contract, you can learn how to test your zkApp.
- The name
compile()
is a metaphor for what this function does: creating prover and verifier functions from your code. It doesn't refer to literal "compilation" of JS into a circuit representation. The circuit representation of your code is created by executing it, not by compiling it. Also, the prover function still includes the execution of your JS code as one step.↩ - Fun fact:
LocalBlockchain
literally uses the same OCaml code for transaction validation and application that the Mina node uses; it's compiled to JS with js_of_ocaml.↩