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.
The shielder
is smart contract that holds:
notes
-- a binary Merkle Tree of a fixed depthH
-- each node in this tree is aScalar
element. The leaves in the tree hold hashes of userNotes
(see below).nullifier_set
-- a set of elements of typeScalar
whose 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
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 ZK-ID and Registrars
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() -> Account
Creates a new account.fn hash(acc: Account) -> Scalar
Hashing to aScalar
(field element)fn update(acc: Account, op: Operation) -> Account
update
is a state transition function for Accounts, given anOperation
such asadd 2 ETH
orsubtract 5 AZERO
The set of operations depends on what do we want to support exactly. But one should have in mind something akin to:
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:
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
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 the following ZK-relation:
What matters to us is that R_update_account
should be possible to write as a small arithmetic circuit so that SNARKs for R_update_account
can be generated efficiently (prover efficiency). It is also not a coincidence that the public inputs of R_update_account
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
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
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 use wants to keep hidden. For instance when transferring funds to a different user the
op_priv
might 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
other
Array 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
AccountSimple
asAccount
and theOperation
type is similar toOperationSimple
then we could just useOpPub = Operation
andOpPriv = ()
(unit type -- "empty").If we use
AccountAdvanced
asAccount
then theOperation
type needs to contain more details than justOperationSimple
-- indeed if the user makesdeposit
operation with+10 ETH
thenop
must contain information which cell of theother
Array should be modified and how. So one can think thatop_priv
specifies the non-deterministic details ofop
whileop_pub
is just a "human readable" representation ofop
.
Updating Notes
Using R_update_account
as a black box, we can formulate the relation that's needed to update notes
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: in practice, for efficiency the R_update_account
instead of having op_pub
as a public input would likely be parametrized by different "variants" the op_pub
can come in. For instance we would have R_update_account_deposit
and R_update_account_withdraw
etc. that take input specific to deposit
and withdraw
variants. The reason this is better in practice is that the efficiency of the prover depends on the circuit size, and if we support all variants in one circuit, the circuit becomes unnecessarily big.
Transactions updating Notes
Finally we are able to write the pseudocode for a transaction the user sends to update its note
The above should be familiar for those who have studied privacy systems like ZCash.
The first instruction shielder.public_exec(op_pub)
performs all operations on the "public state" that the operation op_pub
requires. Below we give some examples. It is important to note that even though public_exec(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
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
Last updated