Notes and Accounts
Here we describe the basic shielder design, then: in section ZK-ID and Registrars we enrich it with ZK-IDs for sybil resistance and in section Anonymity Revokers we propose an improvement that helps in fighting bad actors (see also Design against Bad Actors).
The shielder is smart contract that holds:
notes-- a binary Merkle Tree of a fixed depthH-- each node in this tree is aScalarelement. The leaves in the tree hold hashes of userNotes(see below).nullifier_set-- a set of elements of typeScalarwhose purpose is to invalidate old notesroots-- a list of all historical Merkle roots, needed for technical reasonsOther less relevant storage items that we omit for brevity.
Notes
Each leaf of the notes Merkle Tree is a hash of a note. The Note is a data structure
struct Note {
id: Scalar, // the ZK-ID of a user
trapdoor: Scalar, // a secret needed to prove ownership of the note
nullifier: Scalar, // a secret used to invalidate the note
account_hash: Scalar, // the hash of the user's Account state
} We note that because 1) we store hashes of Note in the Merkle Tree, and 2) because trapdoor stays secret forever (only the user knows it), the id and the account stay private even if nullifier is revealed.
The ZK-ID is discussed in more detail in ZK-ID and Registrars however you can just think of it as the private key of the user.
Accounts
Instead of describing concretely what the Account structure is, we instead abstractly define the operations/properties that accounts should have. By adopting Rust terminology, we define the Account "trait", i.e., specify all the methods that should be defined on accounts.
fn new() -> AccountCreates a new account.fn hash(acc: Account) -> ScalarHashing to aScalar(field element)fn update(acc: Account, op: Operation) -> Accountupdateis a state transition function for Accounts, given anOperationsuch asadd 2 ETHorsubtract 5 AZERO
The set of operations depends on what do we want to support exactly. But one should have in mind something akin to:
enum OperationSimple {
depositFT(Amount, TokenId, AccountId),
withdrawFT(Amount, TokenId, AccountId),
depositNFT(Id, AccountId),
withdrawNFT(Id, AccountId),
}There are additional technical details regarding the description of operations that arise because of details on how accounts are represented and accessed, but they are not essential for high-level understanding.
The simplest possible account structure that supports just a fixed list of fungible tokens would look as follows:
AccountSimple {
balance_AZERO: Scalar,
balance_USDT: Scalar,
balance_USDC: Scalar,
balance_wETH: Scalar,
}this structure is very simple and allows to implement all the required methods assuming that there are just two possible operations depositFT and withdrawFT . The downside is that it's not easily extendable to more token types and/or NFTs. So it might be beneficial to use a more complex structure, like below
AccountAdvanced {
balance_AZERO: Scalar,
other: Array<Scalar, 256>,
}The other field is meant to be an array of 256 entries, each of which is an asset, either FT or NFT, represented as a hash, for instance, hash(ETH, 4) would represent 4 ETH. This account structure is certainly more flexible but it poses an issue when it comes to hashing it and proving correct updates efficiently. More specifically, we are interested in efficiently proving ZK-relations of the following form:
relation R_update_account_op
// op is a particular operation, like withdraw or deposit, along with all
// required arguments, like amount or tokenId
inputs:
- h_acc_old: Scalar,
- h_acc_new: Scalar,
witnesses:
- acc_new: Account,
- acc_old: Account,
constraints:
1. acc_new = Account::update(acc_old, op)
2. h_acc_old = Account::hash(acc_old) // Account::hash is the hash method of the Account trait
3. h_acc_new = Account::hash(acc_new) What matters to us is that for each operation opthe relation R_update_account_op should be possible to write as a small arithmetic circuit so that SNARKs for R_update_account_op can be generated efficiently (prover efficiency). It is also not a coincidence that the public inputs of R_update_account_op are hashes of acc_old and acc_new and not the values itself. The account size might be significant (as in AccountAdvanced) and hence we don't want them explicitly included in the circuit. Even though the constraints mention acc_old and acc_new the circuit does not have to unpack whole accounts as long as the Account::hash is smart enough (for instance it can Merklize other in AccountAdvanced). This way there is hope to make the size of the R_update_account_op circuit logarithmic (or even constant) in the size of Account.
Operations
For maximum flexibility and to enable certain less trivial use patterns we introduce an abstraction layer on Operation. Namely we assume that each operation op: Operation can be broken into:
op_priv: OpPriv- the "private" part of the operation that the user does not revealop_pub: OpPub- the "public" part of the operation that is visible in the transaction
Moreover we assume there is a function
fn combine(op_priv: OpPriv, op_pub: OpPub) -> Option<Operation>which allows to extract an Operation like above given the public and private counterparts. Note that the output of combine is Option<Operation> and not Operation to signify that it can fail -- it will be apparent from the subsequent examples why is that.
The intuition to keep in mind is that op_pub in plaintext will be attached to a transaction the user sends (part of calldata) whereas op_priv will be only part of the witness of a ZK-relation that the user proves when executing the transaction. Typically op_priv is used to hold one of:
Data that the user wants to keep hidden. For instance when transferring funds to a different user the
op_privmight contain the recipient "address" and transferred amount.Data that is not necessary for public execution (see below in the description of transaction) of the operation and is just a technical detail related to how Accounts are represented. For instance we might want to include details on which index of the
otherArray is used to save data about a particular asset when usingAccountAdvanced
One interesting option would be to set op_priv = op and op_pub = hash(op) -- this makes the size of op_pub just 1 Scalar which is good for the verifier complexity. Using some salt, to randomize the hash can even allow us to gain full privacy. This however might not be viable for operations like Deposit where the shielder contract is required to accept a public token transfer of a particular amount, and thus couldn't be done when amount is private.
Examples:
If we use
AccountSimpleasAccountand theOperationtype is similar toOperationSimplethen we could just useOpPub = OperationandOpPriv = ()(unit type -- "empty").If we use
AccountAdvancedasAccountthen theOperationtype needs to contain more details than justOperationSimple-- indeed if the user makesdepositoperation with+10 ETHthenopmust contain information which cell of theotherArray should be modified and how. So one can think thatop_privspecifies the non-deterministic details ofopwhileop_pubis just a "human readable" representation ofop.
Updating Notes
Using R_update_account_op as a black box, we can formulate the relation that's needed to update notes with respect to the operation op
relation R_update_note_op
// op_pub: OpPub is the public part of the operation op to be performed
inputs:
- h_note_new: Scalar,
- merkle_root: Scalar,
- h_nullifier_old: Scalar,
witnesses:
- note_new, note_old: Note,
- trapdoor_new, trapdor_old: Scalar
- nullifier_new, nullifier_old: Scalar,
- proof: MerkleProof
- op_priv: OpPriv
- id: Scalar
constraints:
1. h_note_new = hash(note_new)
2. note_new = Note { id, trapdoor_new, nullifier_new, h_acc_new }
3. h_note_old = hash(note_old)
4. note_old = Note { id, trapdoor_old, nullifier_old, h_acc_old }
5. h_nullifier_old = hash(nullifier_old)
6. verify_merkle_proof(merkle_root, h_note_old, proof)
7. op = combine(op_pub, op_priv)
8. R_update_account(op, h_acc_old, h_acc_new)The hash of the nullifier is published, so that the contract can add it to nullifier_set, which prevents spending the same note again. The reason for not publishing the nullifier itself is to prevent a frontrunning attack. Specifically, a bad actor could intercept the user's nullifier, create their own note with that nullifier, and spend it before the user manages to spend it – thereby invalidating the user's note.
Note: our relations are parametrized by operation types (there is one for deposit, one for withdraw: generally one for each variant of `Operation`). Depending on the set of operations and their inputs, it's sometimes possible to define generic relations that can handle multiple different operations. This way it might be possible to hide the type of the performed operation at the cost of being required to build large, generic ZK-circuits that handle a few operations at once.
Transactions updating Notes
Finally we are able to write the pseudocode for a transaction the user sends to update its note
transaction update_note_op
inputs:
- op_pub: OpPub,
- proof: ZkProof,
- h_nullifier_old: Scalar,
- merkle_root: Scalar,
- h_note_new: Scalar,
execution:
- shielder.public_exec_op(op_pub)
- assert: merkle_root is the current or historical root of shielder.notes
- assert: h_nullifier_old not in shielder.nullifier_set
- v = ZK-Verifier(R_update_note_op) // initialize verifier for the relation R_update_note_op
- assert: v.verify(proof; (op_pub, h_note_new, merkle_root, h_nullifier_old))
- shielder.notes.add_leaf(h_note_new)
- shielder.nullifier_set.add(h_nullifier_old)The above should be familiar for those who have studied privacy systems like ZCash.
The first instruction shielder.public_exec_op(op_pub) performs all operations on the "public state" that the operation op with public params op_pub requires. Below we give some examples. It is important to note that even though public_exec_op(op_pub) might perform some token transfers etc. as a first operation in the transaction, it will be rolled back in case some later operation fails (such as proof verification), so in fact it does not matter much where is this instruction placed within the function body.
Example: depositFT operation
// Below implementation for the depositFT variant
fn public_exec_depositFT(op_pub: OpPub) {
let depositFT { amount, token_id, user } = op_pub;
assert allowance(user, shielder) >= amount;
transfer amount of token_id token from user to shielder;
}Note that in the above if the user has not given enough allowance to the shielder contract, then the assert fails and hence the execution of update_note fails too.
The above is what the "public" part of the operation does. As mentioned before, the "full" version op: Operation that arises when op_pub is combined with the op_priv part is used to update the user's private account. What Account::update(acc, op) should do in this case is quite straightforward, depending on the specifics of Account this might be:
either incrementing one of the hardcoded fields (
AccountSimple), or,adding the tokens to one of the cells in
acc.other(AccountAdvanced).
Example: withdrawFT operation
// Below implementation for the withdrawFT variant
fn public_exec_withdrawFT(op: Operation) {
let withdrawFT { amount, token_id, user } = op_pub;
transfer amount of token_id token from shielder to user;
}Last updated
Was this helpful?