For the complete documentation index, see llms.txt.
Skip to main content

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).
Data availability

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:

history_import
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:

prove_note_inclusion
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_local_note_was_valid_by;

let header = self.context.get_anchor_block_header();
assert_local_note_was_valid_by(header, hinted_note, &mut self.context);

This verifies both:

  1. The note was included in the note hash tree
  2. The note's nullifier was not in the nullifier tree

The assert_local_note_was_* helpers only work for the executing contract's own notes: this is because it is not possible for a contract to retrieve the app-siloed nullifier hiding secret key that corresponds to another contract. To prove facts about another contract's notes, use assert_note_existed_by, which supports notes of any contract (see Prove note inclusion).

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);
warning

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 nullified:

use aztec::history::note::assert_local_note_was_nullified_by;

let header = self.context.get_anchor_block_header();
assert_local_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:

FunctionModulePurpose
assert_note_existed_byhistory::noteProve note exists in note hash tree (any contract)
assert_local_note_was_valid_byhistory::noteProve own note exists and is not nullified
assert_local_note_was_nullified_byhistory::noteProve own note's nullifier is in nullifier tree
assert_local_note_was_not_nullified_byhistory::noteProve own note's nullifier is not in nullifier tree
assert_nullifier_existed_byhistory::nullifierProve a siloed nullifier exists
assert_nullifier_did_not_exist_byhistory::nullifierProve a siloed nullifier does not exist
assert_contract_bytecode_was_published_byhistory::deploymentProve a contract's bytecode was published
assert_contract_bytecode_was_not_published_byhistory::deploymentProve a contract's bytecode was not published
assert_contract_was_initialized_byhistory::deploymentProve a contract was initialized
assert_contract_was_not_initialized_byhistory::deploymentProve a contract was not initialized
public_storage_historical_readhistory::storageRead 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.