Proving historic state
This guide shows you how to prove facts about Aztec's historical state from inside a private function: that a note existed, that a nullifier was or wasn't present, that a contract was deployed, or what a public storage slot held at a past block.
Each proof is a Merkle membership (or non-membership) proof against a state tree root committed in a past block header: the note hash tree, the nullifier tree, or the public data tree. (The Archive tree, by contrast, holds a hash of each block header and is what proves a header is canonical.) Private functions always read from a past block header rather than the live chain tip, because the chain advances while a proof is generated on your device. The proofs therefore tell you what was true once every transaction in the chosen block had executed.
Prerequisites
- An Aztec contract project set up
- Understanding of Aztec's note and nullifier system
What you can prove
You can create proofs for these elements at any past block height:
- Note inclusion - prove a note existed in the note hash tree
- Note validity - prove a note existed and wasn't nullified at a specific block
- Nullifier inclusion/non-inclusion - prove a nullifier was or wasn't in the nullifier tree
- Contract deployment - prove a contract's bytecode was published or initialized
- Public storage - read the value a public storage slot held at a past block
Common use cases:
- Verify ownership of an asset from another contract without revealing which specific note
- Prove eligibility based on historical state (e.g., "owned tokens at block X")
- Claim rewards based on past contributions (see the claim contract for a complete example)
Choosing a block header
Every function below takes a BlockHeader. Two getters on the private context provide one:
self.context.get_anchor_block_header()returns the transaction's anchor block header. The protocol's circuits verify it once per transaction, so using it adds no extra constraints to your function. The protocol requires a transaction to expire within 24 hours of its anchor block, so the anchor is always a block from the last 24 hours.self.context.get_block_header_at(block_number)returns the header of any block at or before the anchor block, letting you prove against older state at the cost of extra constraints (see Prove at a specific historical block).
Producing these proofs requires the historical state trees (note hashes, nullifiers, public storage) for the chosen block. Many nodes prune this data after a few hours, so proving against older blocks requires a node that retains it, such as an archive node.
Prove note inclusion
Import the function:
use aztec::history::note::assert_note_existed_by;
Source code: noir-projects/noir-contracts/contracts/app/claim_contract/src/main.nr#L5-L7
Prove a note exists in the note hash tree:
let header = self.context.get_anchor_block_header();
let confirmed_note = assert_note_existed_by(header, hinted_note);
Source code: noir-projects/noir-contracts/contracts/app/claim_contract/src/main.nr#L41-L44
Prove note validity
To prove a note was valid (existed AND wasn't nullified) at a historical block:
use aztec::history::note::assert_note_was_valid_by;
let header = self.context.get_anchor_block_header();
assert_note_was_valid_by(header, hinted_note, &mut self.context);
This verifies both:
- The note was included in the note hash tree
- The note's nullifier was not in the nullifier tree
Prove at a specific historical block
To prove against state at a specific past block (not just the anchor block):
use aztec::history::note::assert_note_existed_by;
let historical_header = self.context.get_block_header_at(block_number);
assert_note_existed_by(historical_header, hinted_note);
Using get_block_header_at adds ~3k constraints to prove Archive tree membership. The anchor block header is effectively free since it's verified once per transaction.
Prove a note was nullified
To prove a note has been spent/nullified:
use aztec::history::note::assert_note_was_nullified_by;
let header = self.context.get_anchor_block_header();
assert_note_was_nullified_by(header, confirmed_note, &mut self.context);
Prove contract bytecode was published
To prove a contract's bytecode was published at a historical block:
use aztec::history::deployment::assert_contract_bytecode_was_published_by;
let header = self.context.get_anchor_block_header();
assert_contract_bytecode_was_published_by(header, contract_address);
You can also prove a contract was initialized (constructor was called):
use aztec::history::deployment::assert_contract_was_initialized_by;
use aztec::oracle::get_contract_instance::get_contract_instance;
let header = self.context.get_anchor_block_header();
let instance = get_contract_instance(contract_address);
assert_contract_was_initialized_by(header, contract_address, instance.initialization_hash);
Read historical public storage
To read the value a public storage slot held at a past block in a private function, use public_storage_historical_read. It returns the stored value and constrains it against the public data tree root in the given block header:
use aztec::history::storage::public_storage_historical_read;
let header = self.context.get_anchor_block_header();
let value = public_storage_historical_read(header, storage_slot, contract_address);
An uninitialized slot reads as 0.
Because this proves the value against a past block header rather than reading the live chain tip, a private function can read public state this way without enqueuing a public call. Enqueuing a public call is only needed to read the current value or to write to public storage.
Higher-level state variables build on this. Reading a PublicImmutable or DelayedPublicMutable from a private function performs a historical public storage read internally (through WithHash), so you rarely need to call public_storage_historical_read directly. Both types are designed so that a historical read is also a correct read of the current value: a PublicImmutable can only be initialized once, so its value never changes, while a DelayedPublicMutable delays every write and sets the transaction's expiration timestamp so the transaction is only valid for as long as the value it read still holds.
Available history functions
The aztec::history module provides these functions:
| Function | Module | Purpose |
|---|---|---|
assert_note_existed_by | history::note | Prove note exists in note hash tree |
assert_note_was_valid_by | history::note | Prove note exists and is not nullified |
assert_note_was_nullified_by | history::note | Prove note's nullifier is in nullifier tree |
assert_note_was_not_nullified_by | history::note | Prove note's nullifier is not in nullifier tree |
assert_nullifier_existed_by | history::nullifier | Prove a siloed nullifier exists |
assert_nullifier_did_not_exist_by | history::nullifier | Prove a siloed nullifier does not exist |
assert_contract_bytecode_was_published_by | history::deployment | Prove a contract's bytecode was published |
assert_contract_bytecode_was_not_published_by | history::deployment | Prove a contract's bytecode was not published |
assert_contract_was_initialized_by | history::deployment | Prove a contract was initialized |
assert_contract_was_not_initialized_by | history::deployment | Prove a contract was not initialized |
public_storage_historical_read | history::storage | Read a public storage value at a historical block |
The nullifier functions take a siloed nullifier: the nullifier hashed together with the contract address, which is the value actually stored in the global nullifier tree. Use compute_siloed_nullifier to convert an inner nullifier (the value passed to push_nullifier_unsafe) into its siloed form.