Mina
Mina is a ZK (zero-knowledge) blockchain that leverages zk-SNARKs (zero-knowledge succinct non-interactive arguments of knowledge) to maintain a constant-sized blockchain. This unique approach ensures that the blockchain remains lightweight and scalable, regardless of the number of transactions.
The Mina Berkeley upgrade introduced smart contracts to the Mina ecosystem, significantly expanding the possibilities for developers. These smart contracts, known as zkApps, natively support zk-SNARKs, providing distinct advantages over traditional smart contracts. One of the key benefits is the enhanced privacy and security that zk-SNARKs offer, allowing for more secure and private transactions and interactions on the blockchain.
zkApp
There are several main difference between zkApp and smart contracts:
- Native Support of zk-SNARKs: Solidity currently has precompiled contracts for SNARK checks; however, they do not support recursive SNARKs. On the other hand, Mina provides all the tools necessary for the easy use of recursive SNARKs, significantly expanding the boundaries of application development(By the way, this may soon become irrelevant, as the Mina Protocol is currently working on delivering recursive SNARKs to Ethereum https://x.com/MinaProtocol/status/1808911187771326966).
- Memory Constraints: In EVM smart contracts, you have 2²⁵⁶ storage slots to use, with the only limitation being the cost of storage. However, in Mina, each smart contract has only eight 256-bit slots available. Typically, all data is stored off-chain, and the smart contract only stores information to prove this off-chain data. Merkle trees are frequently used, where only the root of the tree is stored on-chain, and all leaves are stored off-chain. When a user wants to call a smart contract function, they provide the off-chain data and proof of it.
- Transaction Execution flow: Ordinary, smart contracts operate in an order-execute architecture, where all transactions are ordered inside a block and then executed by block producers. This resolves most concurrent transaction problems since transactions are executed over the actual state. Mina adopts a different approach — execute-order-validate. In Mina zkApps — smart contracts calls are fully executed on the client side, and the output of this execution is a proof (of execution) sent in a transactions. Then all transactions are ordered by block producer, which then update the state transaction by transaction, additionally checking if all conditions are satisfied. This approach distributes the computational load from block producers to users and provides privacy for users since they do not have to reveal function inputs. However, it has one major drawback, which we will illustrate further.
Token example
Caution: We used a token as an illustrative example. However, Mina has a different approach to custom tokens. If you want to create your own token on Mina, please follow the official instructions here https://docs.minaprotocol.com/zkapps/tutorials/custom-tokens.
Imagine you want to create your own token. For that purpose, you will need to store at least the user balances and the total supply.
Total supply is a single number, so we can store it on-chain. User balances, on the other hand, are a mapping from users to their balances. For that purpose, we will create a Merkle Map and store its root on-chain, with all other data stored off-chain.
Here how our onchain storage will look like.
export class Token extends SmartContract {
@state(Field) totalSupply = State<Field>();
@state(Field) balancesRoot = State<Field>();
}
We will construct transferTo in following way.
It will take transfer amount, and user balances and proofs of these values
@method async transferTo(
transferAmount: Field,
balanceABefore: Field,
balanceAWitness: MerkleMapWitness,
balanceBBefore: Field,
balanceBWitness: MerkleMapWitness
)
The first thing to do is check the validity of the first user balance.
let sender = this.sender.getAndRequireSignature();
const [prevRoot, senderHash] =
balanceAWitness.computeRootAndKeyV2(balanceABefore);
this.balancesRoot
.getAndRequireEquals()
.assertEquals(prevRoot, 'Wrong witness for balances A');
Poseidon.hash(sender.toFields()).assertEquals(
senderHash,
'Only owner can transfer'
);
transferAmount.assertLessThanOrEqual(
balanceABefore,
"Can't transfer more then you have"
);
Next, we need to check the second user balance. It is worth noting that we check the proof of the second user balance not against the current contract state, but against the state that we obtain after the balance of the first user is changed. Therefore, we first need to compute this state. This is important, because first leaf of Merkle Map(i.e. balance of sender) change would invalidate Merkle Witness for receiver balance otherwise.
const newValueA = balanceABefore.sub(transferAmount);
const [newRootA] = balanceAWitness.computeRootAndKeyV2(newValueA);
And then we can check second user balance.
const [prevRootB] = balanceBWitness.computeRootAndKeyV2(balanceBBefore);
prevRootB.assertEquals(newRootA, 'Wrong witness for balanceB');
With all checks successfully passed, we can compute the new root of the balances Merkle Map and update the on-chain value.
const newValueB = balanceBBefore.add(transferAmount);
const [newRoot] = balanceBWitness.computeRootAndKeyV2(
balanceBBefore.add(newValueB)
);
this.balancesRoot.set(newRoot);
Full code example can be found here token-reduce-example.
Concurrent state update problem
You can see similar lines in code:
this.balancesRoot
.getAndRequireEquals()
.assertEquals(prevRoot, 'Wrong witness for balances A');
These are the preconditions. We check that the current state of balances
is equal to the state passed as function arguments. The greatest interest in these lines lies in the timing of computation. We know prevFromRoot
at the time of proof generation, but this.balancesRoot
is known only at validation time after ordering. This causes the following problem:
Imagine two users trying to call transferFrom
at the same time. They will have similar prevFromRoot
values because they are operating with the current version of the balances
Merkle Tree. Both will successfully create transactions. However, at the time of ordering, one of the two transactions will be placed before the other. The first transaction will be accepted by the block producer and will update the chain, but the second transaction will fail the preconditions check because it used a state that is now outdated.
Thus, we are practically limited to one transfer transaction per block, which is catastrophic for the future of our token.
However, Mina Protocol developers incorporated a design pattern to address such issues.
Actions/reducers
Actions are stored as a stack of arbitrary provable objects that you define. This stack is not updated by the user but by block producers during the block production phase. Consequently, many actions can be placed on the stack without causing concurrent issues. When you dispatch an action, you are simply adding another element to the stack.
In our example, dispatching an action creates a request for a transfer. However, the transfer itself does not happen immediately; it will only occur when this action is reduced. In some cases, you may not need to reduce the action at all, such as when your zkApp only needs to verify that an action was dispatched once. However, in most cases, reducing the action will be necessary.
To use reducers we should declare type of our action. This can be any provable type, such as Field, UInt64, Struct and other.
export class TransferAction extends Struct({
from: PublicKey,
to: PublicKey,
amount: Field,
}) {}
export class AToken extends SmartContract {
@state(Field) totalSupply = State<Field>();
@state(Field) balancesRoot = State<Field>();
reducer = Reducer({ actionType: TransferAction });
}
Here is an example how our token transfer would look with action/reducers:
@method async transferTo(to: PublicKey, amount: Field) {
// Here we skips all checks that were on transferTo of Token contract.
// We just add another request for transfer. It do not change balances now, however it will change it later, when we will call reduce
this.reducer.dispatch(
new TransferAction({
from: this.sender.getAndRequireSignature(),
to,
amount,
})
);
}
But as mentioned earlier, this function does not update state, but push action to action Merkle List. For this to be applied to contract state, a reducer should be called.
In the following articles we will take a closer look on actions/reducers and how to implement them