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

docs(yellow-paper): Note hash, nullifier, and public data trees #3518

Merged
merged 1 commit into from
Dec 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions yellow-paper/docs/state/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
title: State
---

# State

Global state in the Aztec Network is represented by a set of Merkle trees: the [Note Hash tree](./note_hash_tree.md), [Nullifier tree](./nullifier_tree.md), and [Public Data tree](./public_data_tree.md) reflect the latest state of the chain.
Copy link
Contributor

Choose a reason for hiding this comment

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

There are additional state. But I will add it in an extension to this pr.


Merkle trees are either [append-only](./tree_impls.md#append-only-merkle-trees), for storing immutable data, or [indexed](./tree_impls.md#indexed-merkle-trees), for storing data that requires proofs of non-membership.
Copy link
Contributor

Choose a reason for hiding this comment

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

the wording with immutable can be a little weird. The nullifiers are immutable, but the leaves in the tree won't be because of the successor references.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

True, but it's not a property of indexed trees to have immutable values (eg the public data tree is indexed and not immutable).


import DocCardList from '@theme/DocCardList';

<DocCardList />
25 changes: 25 additions & 0 deletions yellow-paper/docs/state/note_hash_tree.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Note Hash Tree

The Note Hash tree is an [append-only Merkle tree](./tree_impls.md#append-only-merkle-trees) that stores siloed note hashes as its elements. Each element in the tree is a 254-bit altBN-254 scalar field element. This tree is part of the global state, and allows to prove existence of private notes via Merkle membership proofs.

Note commitments are immutable once created, since notes cannot be modified. Still, notes can be consumed, which means they can no longer be used. To preserve privacy, a consumed note is not removed from the tree, otherwise it would be possible to link the transaction that created a note with the one that consumed it. Instead, a note is consumed by emitting a deterministic [nullifier](./nullifier_tree.md).
Copy link
Contributor

Choose a reason for hiding this comment

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

We should probably be very consistent on calling either commitments or note hashes.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Agree. Do we have a consensus on which we should use?


Contracts emit new note commitments via the `new_commitments` in the `CircuitPublicInputs`, which are subsequently [siloed](./tree_impls.md#siloing-leaves) per contract by the Kernel circuit. Siloing the commitment ensures that a contract cannot emit a commitment for a note that could be used for a different contract.

The Kernel circuit also guarantees uniqueness of commitments by further hashing them with a nonce, derived from the transaction identifier and the index of the commitment within the transaction. Uniqueness means that a note with the same contents can be emitted more than once, and each instance can be independently nullified. Without uniqueness, two notes with the same content would yield the same commitment and nullifier, so nullifying one of them would flag the second one as nullified as well.

The pseudocode for siloing and making a commitment unique is the following, where each `hash` operation is a Pedersen hash with a unique generator index, indicated by the constant in all caps.

```
fn compute_unique_siloed_commitment(commitment, contract, transaction):
let siloed_commitment = hash([contract, commitment], SILOED_COMMITMENT)
let index = index_of(commitment, transaction.commitments)
let nonce = hash([transaction.tx_hash, index], COMMITMENT_NONCE)
return hash([nonce, siloed_commitment], UNIQUE_COMMITMENT)
```

The unique siloed commitment of a note is included in the [transaction `data`](../transactions/tx-object.md), and then included into the Note Hash tree by the sequencer as the transaction is included in a block.

The protocol does not enforce any constraints to the commitment emitted by an application. This means that applications are responsible for including a `randomness` field in the note hash to make the commitment _hiding_ in addition to _binding_. If an application does not include randomness, and the note preimage can be guessed by an attacker, it makes the note vulnerable to preimage attacks, since the siloing and uniqueness steps do not provide hiding.

Furthermore, since there are no constraints to the commitment emitted by an application, an application can emit any value whatsoever as a `new_commitment`, including values that do not map to a note hash.
20 changes: 20 additions & 0 deletions yellow-paper/docs/state/nullifier_tree.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Nullifier Tree

The Nullifier tree is an [indexed Merkle tree](./tree_impls.md#indexed-merkle-trees) that stores nullifier values. Each value stored in the tree is a 254-bit altBN-254 scalar field element. This tree is part of the global state, and allows to prove non-existence of a nullifier when a note is consumed.

Nullifiers are asserted to be unique during insertion, by checking that the inserted value is not equal to the value and next-value stored in the prior node in the indexed tree. Any attempt to insert a duplicated value is rejected.

Contracts emit new nullifiers via the `new_nullifiers` in the `CircuitPublicInputs`. Same as elements in the [Note Hash tree](./note_hash_tree.md), nullifiers are [siloed](./tree_impls.md#siloing-leaves) per contract by the Kernel circuit before being inserted in the tree, which ensures that a contract cannot emit nullifiers that affect other contracts.

```
fn compute_siloed_nullifier(nullifier, contract):
return hash([contract, nullifier], OUTER_NULLIFIER)
```

Nullifiers are primarily used for privately marking notes as consumed. When a note is consumed in an application, the application computes and emits a deterministic nullifier associated to the note. If a user attempts to consume the same note more than once, the same nullifier will be generated, and will be rejected on insertion by the nullifier tree.

Nullifiers provide privacy by being computed using a deterministic secret value, such as the owner siloed nullifier secret key, or a random value stored in an encrypted note. This ensures that, without knowledge of the secret value, it is not possible to calculate the associated nullifier, and thus it is not possible to link a nullifier to its associated note commitment.

Applications are not constrained by the protocol on how the nullifier for a note is computed. It is responsibility of the application to guarantee determinism in calculating a nullifier, otherwise the same note could be spent multiple times.

Furthermore, nullifiers can be emitted by an application just to ensure that an action can be executed only once, such as initializing a value, and are not required to be linked to a note commitment.
19 changes: 19 additions & 0 deletions yellow-paper/docs/state/public_data_tree.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Public Data Tree

The Public Data tree is an [indexed Merkle tree](./tree_impls.md#indexed-merkle-trees) that stores public-state key-value data. Each item stored in the tree is a key-value pair, where both key and value are 254-bit altBN-254 scalar field elements. Items are sorted based on their key, so each indexed tree leaf contains a tuple with the key, the value, the next higher key, and the index in the tree for the next higher key. This tree is part of the global state, and is updated by the sequencer during the execution of public functions.

The Public Data tree is implemented using an indexed Merkle tree instead of a sparse Merkle tree in order to reduce the tree height. A lower height means shorter membership proofs.

Keys in the Public Data tree are [siloed](./tree_impls.md#siloing-leaves) using the contract address, to prevent a contract from overwriting public state for another contract.

```
fn compute_siloed_public_data_item(key, value, contract):
let siloed_key = hash([contract, key], PUBLIC_DATA_LEAF)
return [siloed_key, value]
```

When reading a key from the Public Data tree, the key may or may not be present. If the key is not present, then a non-membership proof is produced, and the value is assumed to be zero. When a key is written to, either a new node is appended to the tree if the key was not present, or its value is overwritten if it was.

Public functions can read from or write to the Public Data tree by emitting `contract_storage_read` and `contract_storage_update_requests` in the `PublicCircuitPublicInputs`. The Kernel circuit then siloes these requests per contract.

Contracts can store arbitrary data at a given key, which is always stored as a single field element. Applications are responsible for interpreting this data. Should an application need to store data larger than a single field element, they are responsible for partitioning it across multiple keys.
25 changes: 25 additions & 0 deletions yellow-paper/docs/state/tree_impls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Tree implementations

Aztec relies on two Merkle tree implementations in the protocol: append-only and indexed Merkle trees.

## Append-only Merkle trees

In an append-only Merkle tree new leaves are inserted in order from left to right. Existing leaf values are immutable and cannot be modified. These tree are useful to represent historic data, as new entries are added as new transactions and blocks are processed, and historic data is not altered.

Append-only trees allow for more efficient syncing than sparse trees, since clients can sync from left to right starting with their last known value. Updates to the tree root from new leaves can be computed just by keeping the rightmost boundary of the tree, and batch insertions can be computed with fewer hashes than in a sparse tree. Append-only trees also provide cheap historic snapshots, as older roots can be computed by completing the merkle path from a past left subtree with an empty right subtree.

## Indexed Merkle trees

Indexed Merkle trees, introduced [here](https://eprint.iacr.org/2021/1263.pdf), allow for proofs of non-inclusion more efficiently than sparse Merkle trees. Each leaf in the tree is a tuple with the leaf value, the next higher value in the tree, and the index of the leaf where that value is stored. New nodes are inserted left to right, as in the append-only tree, but existing nodes can be modified to update the next value and its pointer. Indexed Merkle trees behave as a Merkle tree over a sorted linked list.

Assuming the indexed Merkle tree invariants hold, proving non-membership of a value `x` then requires a membership proof of the node with value lower than `x` and a next higher value greater than `x`. The cost of this proof is proportional to the height of the tree, which can be set according to the expected number of elements to be stored in the tree. For comparison, a non-membership proof in a sparse tree requires a tree with height proportional to the size of the elements, so when working with 256-bit elements, 256 hashes are required for a proof.

Refer to [this page](https://docs.aztec.network/concepts/advanced/data_structures/indexed_merkle_tree) for more details on how insertions, updates, and membership proofs are executed on an Indexed Merkle tree.

<!-- Q: should we embed the diagrams and pseudocode here, instead of linking? -->

## Siloing leaves

In several trees in the protocol we indicate that its leaves are "siloed". This refers to hashing the leaf value with a siloing value before inserting it in the tree. The siloing value is typically an identifier of the contract that produced the value. This allows us to store disjoint "domains" within the same tree, ensuring that a value emitted from one domain cannot affect others.

To guarantee the siloing of leaf values, siloing is performed by a trusted protocol circuit, such as the kernel or rollup circuits, and not by an application circuit. Siloing is performed by Pedersen hashing the contract address and the value.