Getting started with Aztec smart contracts
Aztec ↗ is an L2 network that uses zero-knowledge proofs ↗ to enable privacy-preserving contracts. Here you will learn how to write smart contracts that allow users keep certain information private.
Development environment
We start by using the local development environment provided by Aztec.
-
Start the environment, as explained here ↗.
-
Start the Aztec environment.
Terminal window aztec start --sandboxWait until you see this message:
[20:06:45.643] INFO: cli Aztec Server listening on port 8080 -
Add the test accounts to your wallet.
Terminal window aztec-wallet import-test-accounts
Counter contract
This is one of the simplest blockchain contracts - a counter that anybody can increment and read.
Program
The programming language for Aztec is Noir ↗, a zero-knowledge language roughly based on Rust ↗. Don’t worry if you’re unfamiliar with Noir or Rust; this tutorial explains what you need to know.
-
Create a new project.
Terminal window aztec-nargo new counter --contractcd counter -
Replace
Nargo.tomlwith:[package]name = "counter_contract"authors = [""]compiler_version = ">=0.25.0"type = "contract"[dependencies]aztec = { git = "https://github.com/AztecProtocol/aztec-packages", directory = "noir-projects/aztec-nr/aztec", tag = "v2.1.2" }This file is a standard configuration file. There is one interesting line, the dependency ↗. It is a Noir package that contains the definitions to create Aztec contracts.
-
Replace
src/main.nrwith:use dep::aztec::macros::aztec;#[aztec]pub contract Counter {use aztec::{macros::{functions::{initializer, utility, public}, storage::storage},state_vars::PublicMutable,};#[storage]struct Storage<Context> {counter: PublicMutable<Field, Context>,}#[public]#[initializer]fn constructor() {// Initialize counters to zerostorage.counter.write(0);}#[public]fn increment() {let current = storage.counter.read();storage.counter.write(current + 1);}#[utility]unconstrained fn get_counter() -> Field {storage.counter.read()}} -
Compile the contract.
Terminal window aztec-nargo compileaztec-postprocess-contract -
Deploy the contract (this is a slow process).
Terminal window aztec-wallet deploy --from test0 target/counter_contract-Counter.jsonWait until you see the deployment data, similar to:
[21:35:31.474] INFO: pxe:bb:native Generated IVC proof {"duration":25522.222093999997,"eventName":"circuit-proving"}[21:35:31.748] INFO: pxe:service Sent transaction 0x01e485d313c567508d91c12da16ee9eece6a92bd50925f7ae2a5c162fc60db44Contract deployed at 0x029e67317a774f5e22ea5118033b3d32f9a57a6f175bced5098f1b2b257f54cdContract partial address 0x1ba91e31e35c6c54715b2256454e17edb4c9e00ae318b947c9d6468d742af07eContract init hash 0x1b386d6cd70c2fceeed6551dbbd7396725643db5e2d0a1b930a95b10197b1a7eDeployment tx hash: 0x01e485d313c567508d91c12da16ee9eece6a92bd50925f7ae2a5c162fc60db44Deployment salt: 0x22bb57fd37d0fcad7281709b623433349cf51017f4954493f8ba1b21f1ab7f2aDeployment fee: 102185650Contract stored in database with alias last -
Store the contract address. For example, for the result above, write:
Terminal window COUNTER_ADDR=0x029e67317a774f5e22ea5118033b3d32f9a57a6f175bced5098f1b2b257f54cd
See it in action
-
Store the test user addresses.
Terminal window TEST0=`aztec-wallet get-alias accounts:test0 |& head -1`TEST1=`aztec-wallet get-alias accounts:test1 |& head -1`TEST2=`aztec-wallet get-alias accounts:test2 |& head -1` -
Get the counter value as
test0.Terminal window aztec-wallet simulate get_counter --from test0 --contract-address $COUNTER_ADDRSee the simulation result is
0n. -
Increment the counter.
Terminal window aztec-wallet send increment --from test0 --contract-address $COUNTER_ADDRWait until you see the transaction hash.
Terminal window [23:12:40.041] INFO: pxe:service Sent transaction 0x2a8aad6e000e8627abb866b9a09c670b0c862bd9e0ad628641e43515986689a4Set
TX_HASHto the transaction hash.Terminal window TX_HASH=0x2a8aad6e000e8627abb866b9a09c670b0c862bd9e0ad628641e43515986689a4 -
Get transaction information.
Terminal window aztec get-tx $TX_HASHSee that the transaction output includes the counter’s new value.
Public data writes:Leaf 0x285c4e3dc6727106db840ce0c96066c8e589f4b705ad0d76139e75694b3e4a40 = 0x0000000000000000000000000000000000000000000000000000000000000003Leaf 0x260d11eae90fba8e072dd2cc28bfc8a9f8686ded1c0c2b08121574e634289128 = 0x00000000000000000000000000000000000000000000021e19e0c9baa6f7d434Nullifiers:Unknown nullifier 0x1c71cfeab39e9a7aecf6198ac6901edbbd9faee1ae95804ad07677caeccaa0bbUnknown nullifier 0x199d5c70ae1c74dbc9017205c14c1293eca6acd9162adf01063ff1e555a66275 -
Get the counter again, see it is now
1n.Terminal window aztec-wallet simulate get_counter --from test0 --contract-address $COUNTER_ADDR
How it works
The logic of the account is all at src/main.nr.
use dep::aztec::macros::aztec;Import macro definitions ↗ from the dependency.
#[aztec]pub contract Counter {Declare a public contract.
use aztec::{Import the necessary definitions into the contract.
macros::{functions::{initializer, utility, public}, storage::storage}, state_vars::PublicMutable, };
#[storage]Here we define the contract’s storage.
struct Storage<Context> { counter: PublicMutable<Field, Context>, }Here we define a variable called counter, which is a Field.
A Field can store integer numbers, up to a certain limit (2254 approximately).
Click here to read more about this variable type ↗.
We use PublicMutable to indicate that this variable is public (anybody can read it) and mutable ↗ (the value can change).
In Noir, as in Rust, variables are immutable unless explicitly declared as mutable.
#[public]This function is public ↗, which means that it is executed publicly, the same as functions on an EVM blockchain.
The inputs, outputs, and effects of this function are available to everybody with access to the network.
#[initializer] fn constructor() {This is the constructor ↗, called once when the contract is deployed.
// Initialize counters to zero storage.counter.write(0); }The only thing the constructor does is write zero to counter.
In contrast to Solidity ↗, state variables in Noir are objects with a .write() and .read() functions.
#[public] fn increment() {Read the counter, increment, and update it.
This is another public function.
let current = storage.counter.read(); storage.counter.write(current + 1); }Again, a variable (current) that is actually a constant.
#[utility]This is a utility ↗ function, which means it functions like a view function in Solidity, except that you run it locally instead of asking a network node to do it for you.
unconstrained fn get_counter() -> Field {Because this function is run locally and does not generate a transaction, there is no need to prove that the answer it provided is correct. We presume that the user already trusts the user’s own private execution environment ↗.
storage.counter.read() }}Noir functions return a value by evaluating it at the end of the function. For reasons related to the nature of zero-knowledge proofs, there is no way for a function to end early.
Secret counter contract
Now we can run a completely public contract in Aztec. This is similar to what can be done on an EVM blockchain, except that everything takes significantly longer due to the need to calculate zero-knowledge proofs.
This example extends that by adding per-address private counters plus a public total counter, demonstrating how notes and private state work in Aztec.
Program
-
Create a new directory and move to it.
Terminal window mkdir secret-countercd secret-countermkdir src -
Create a
Nargo.tomlfile:[package]name = "secret_counter"type = "contract"authors = [""]compiler_version = ">=0.25.0"[dependencies]aztec = { git="https://github.com/AztecProtocol/aztec-packages/", tag="v2.1.2", directory="noir-projects/aztec-nr/aztec" }value_note = { git="https://github.com/AztecProtocol/aztec-packages/", tag="v2.1.2", directory="noir-projects/aztec-nr/value-note"}easy_private_state = { git="https://github.com/AztecProtocol/aztec-packages/", tag="v2.1.2", directory="noir-projects/aztec-nr/easy-private-state"}We need two additional libraries:
-
value-note↗, which is used for private state variables that include a number and an address. In Aztec terminology the mechanism for private state variables is called notes ↗. -
easy-private-state↗, which abstracts most of the details of note management.
-
-
Create an
src/main.nrfile:use dep::aztec::macros::aztec;#[aztec]pub contract SecretCounter {use aztec::{macros::{functions::{initializer, utility, private, public}, storage::storage},state_vars::{Map, PublicMutable},protocol_types::address::AztecAddress,};use easy_private_state::EasyPrivateUint;#[storage]struct Storage<Context> {secretCounters: Map<AztecAddress, EasyPrivateUint<Context>, Context>,totalCounter: PublicMutable<Field, Context>,}#[private]#[initializer]fn constructor() { }#[private]fn increment() {let sender: AztecAddress = context.msg_sender();storage.secretCounters.at(sender).add(1, sender);// Enqueue the public function callSecretCounter::at(context.this_address()).incrementTotal().enqueue(&mut context);}#[public]fn incrementTotal() {storage.totalCounter.write(storage.totalCounter.read() + 1);}#[utility]unconstrained fn get_counter(owner: AztecAddress) -> Field {storage.secretCounters.at(owner).get_value()}#[utility]unconstrained fn get_total() -> Field {storage.totalCounter.read()}}
See it in action
-
Create two wallets. We need two wallets here because any notes that any of the wallet accounts can decrypt are readable when you use the wallet. Here we need to show that other accounts cannot read the private counter.
Terminal window aztec-wallet -d wallet1 import-test-accountsaztec-wallet -d wallet2 create-account --register-only -sk 0x0f6addf0da06c33293df974a565b03d1ab096090d907d98055a8b7f4954e120c -a test2Note that
wallet2only contains thetest2user. -
Compile and deploy the contract.
Terminal window aztec-nargo compile && aztec-postprocess-contractaztec-wallet deploy -d wallet1 --from test0 target/secret_counter-SecretCounter.jsonCOUNTER_ADDR=`aztec-wallet -d wallet1 get-alias contracts:last | head -1`echo Counter contract address: $COUNTER_ADDR -
Register the contract on
wallet2so we’ll be able to use it.Terminal window aztec-wallet -d wallet2 register-contract $COUNTER_ADDR target/secret_counter-SecretCounter.json --from test2 --alias $COUNTER_ADDR -
Increment the counter for
test1onwallet1.Terminal window aztec-wallet -d wallet1 send increment --from test1 --contract-address $COUNTER_ADDR -
See that using
wallet1we can see the counter fortest1was incremented, even astest2.Terminal window aztec-wallet -d wallet1 simulate get_counter --from test2 --contract-address $COUNTER_ADDR --args $TEST1 -
See that you can get the total, which is public, using
wallet2.Terminal window aztec-wallet -d wallet2 simulate get_total --from test2 --contract-address $COUNTER_ADDR -
See that on
wallet2, which does not have thetest1’s private key, you see the counter as zeroTerminal window aztec-wallet -d wallet2 simulate get_counter --from test2 --contract-address $COUNTER_ADDR --args $TEST1 -
Create another wallet, import
test1, and see that it gets the correct counter value.Terminal window aztec-wallet -d wallet3 create-account --register-only -sk 0x0aebd1b4be76efa44f5ee655c20bf9ea60f7ae44b9a7fd1fd9f189c7a0b0cdae -a test1aztec-wallet -d wallet3 register-contract $COUNTER_ADDR target/secret_counter-SecretCounter.json --from test1 --alias $COUNTER_ADDRaztec-wallet -d wallet3 simulate get_counter --from test1 --contract-address $COUNTER_ADDR --args $TEST1
How it works
Let’s go over the new parts.
use easy_private_state::EasyPrivateUint;Use the easy_private_state ↗ library, which makes it easier to handle private state variables.
#[storage] struct Storage<Context> { secretCounters: Map<AztecAddress, EasyPrivateUint<Context>, Context>, totalCounter: PublicMutable<Field, Context>, }Here the storage includes a PublicMutable for the total value of the counters, and a Map for the counters themselves.
The Map index is AztecAddress, because the secret counters are per-address.
#[private] #[initializer] fn constructor() { }To deploy a contract it is necessary for it to have a constructor, even if the constructor does not do anything.
#[private]This is a private function.
This means it is executed in the client, and a zero-knowledge proof that it was executed correctly is sent to the public blockchain.
fn increment() { let sender: AztecAddress = context.msg_sender();Our address, the equivalent of msg.sender in Solidity.
storage.secretCounters.at(sender).add(1, sender);storage.secretCounters is the Map.
The field inside it is available as storage.secretCounters.at(sender), which is a EasyPrivateUint<Context> ↗.
When you add ↗ a value, it creates a note (with sender as the recipient) with the value to add.
More note details
Notes ↗ are the Aztec implementation of UTxO ↗.
The code for EasyPrivateUint<Context> ↗ is a good example of how to use notes.
impl EasyPrivateUint<&mut PrivateContext> { // Very similar to `value_note::utils::increment`. pub fn add(self, addend: u64, owner: AztecAddress) { // Creates new note for the owner. let addend_note = ValueNote::new(addend as Field, owner); // Insert the new note to the owner's set of notes. self.set.insert(addend_note).emit(owner, MessageDelivery.CONSTRAINED_ONCHAIN); }Instead of getting the current value, nullifying it, and then creating a new note, the library just adds a note with the value to add.
This saves some processing, and it can allow other users than the owner to add to a value if the contract supports that (SecretContract does not).
For example, if user a sends an asset to user b, user a needs to create a note that adds to user b’s balance, without knowing the current balance.
// Very similar to `value_note::utils::decrement`. pub fn sub(self, subtrahend: u64, owner: AztecAddress) { let options = NoteGetterOptions::with_filter(filter_notes_min_sum, subtrahend as Field);This is an unsigned value, so it cannot be negative.
To ensure that the value subtracted (the subtrahend) is less than the value from which it is subtracted (the minuend), we need to sum up all the values we added earlier.
let notes = self.set.pop_notes(options);Read and nullify all of the existing notes.
let mut minuend: u64 = 0; for i in 0..options.limit { if i < notes.len() { let note = notes.get_unchecked(i);
minuend += note.value() as u64; } }Sum up all the existing notes.
assert(minuend >= subtrahend);Make sure that the result is positive.
// Creates change note for the owner. let result_value = minuend - subtrahend; let result_note = ValueNote::new(result_value as Field, owner); self.set.insert(result_note).emit(owner, MessageDelivery.CONSTRAINED_ONCHAIN); }}Create a note for the new value.
// Enqueue the public function call SecretCounter::at(context.this_address()) .incrementTotal() .enqueue(&mut context); }To update a public state variable, such as totalCounter, we need to be in a public function that runs directly on the blockchain, rather than in a private execution environment (PXE).
This call creates a transaction to increment totalCounter.
#[public] fn incrementTotal() { storage.totalCounter.write(storage.totalCounter.read() + 1); }This is a public function that increment calls to increment the total.
#[utility] unconstrained fn get_counter(owner: AztecAddress) -> Field { storage.secretCounters.at(owner).get_value() }
#[utility] unconstrained fn get_total() -> Field { storage.totalCounter.read() }}This is how we get the values.
Note that utility functions do not have caller information, so it’s necessary for get_counter to get the address as a parameter.
Information leakage
Whenever we have a private state variable, it makes sense to see what an attacker could learn about it from publicly available information.
Let’s assume that test0 and test1 are our attacker and test2 is the target.
-
Have
test0andtest1increment their respective counters twice, and create a file with the relevant information.Terminal window aztec-wallet -d wallet1 send increment --from test0 --contract-address $COUNTER_ADDRaztec-wallet get-tx -d wallet1 transactions:last > txn.logecho ======= >> txn.logaztec-wallet -d wallet1 send increment --from test0 --contract-address $COUNTER_ADDRaztec-wallet get-tx -d wallet1 transactions:last >> txn.logecho ======= >> txn.logaztec-wallet -d wallet1 send increment --from test1 --contract-address $COUNTER_ADDRaztec-wallet get-tx -d wallet1 transactions:last >> txn.logecho ======= >> txn.logaztec-wallet -d wallet1 send increment --from test1 --contract-address $COUNTER_ADDRaztec-wallet get-tx -d wallet1 transactions:last >> txn.logcat txn.log | grep -v \\[ > txn2.log ; mv txn2.log txn.log -
See all the state changes caused by the transactions.
Terminal window cat txn.log | grep -E '===|Leaf|write'The output will be similar to:
Public data writes:Leaf 0x2d906bcf3f4298b3a766f6089adaf902690535b6407a6e7fec71d0aa09450d16 = 0x0000000000000000000000000000000000000000000000000000000000000002Leaf 0x22b8cdbf4e6551f3b0d46647f74289cff3c3478a65fcd5e69ab4e0ab0abaaad7 = 0x00000000000000000000000000000000000000000000021e19e0c9b822d91b38=======Public data writes:Leaf 0x2d906bcf3f4298b3a766f6089adaf902690535b6407a6e7fec71d0aa09450d16 = 0x0000000000000000000000000000000000000000000000000000000000000003Leaf 0x22b8cdbf4e6551f3b0d46647f74289cff3c3478a65fcd5e69ab4e0ab0abaaad7 = 0x00000000000000000000000000000000000000000000021e19e0c9b8212c956c=======Public data writes:Leaf 0x2d906bcf3f4298b3a766f6089adaf902690535b6407a6e7fec71d0aa09450d16 = 0x0000000000000000000000000000000000000000000000000000000000000004Leaf 0x06866ecba67c37f2badf4560ad48639e51ada748b53d0c0ad8d833d33b5b6e0c = 0x00000000000000000000000000000000000000000000021e19e0c9ba8d797bd4=======Public data writes:Leaf 0x2d906bcf3f4298b3a766f6089adaf902690535b6407a6e7fec71d0aa09450d16 = 0x0000000000000000000000000000000000000000000000000000000000000005Leaf 0x06866ecba67c37f2badf4560ad48639e51ada748b53d0c0ad8d833d33b5b6e0c = 0x00000000000000000000000000000000000000000000021e19e0c9ba8bccf608 -
It looks like any time we call
incrementthe data in leaf0x2d906bcf3f4298b3a766f6089adaf902690535b6407a6e7fec71d0aa09450d16gets updated. The value looks like it’s the total counter. We can check that by incrementing a few more times, and comparing the value in the leaf to the value we get fromget_total.Terminal window LEAF=`aztec-wallet get-tx -d wallet1 transactions:last | awk '/Leaf/ {print $2}' | head -1`aztec-wallet -d wallet1 send increment --from test1 --contract-address $COUNTER_ADDR > /dev/nullaztec-wallet get-tx -d wallet1 transactions:last | grep $LEAFaztec-wallet -d wallet1 simulate get_total --from test1 --contract-address $COUNTER_ADDR | grep -v \\[aztec-wallet -d wallet1 send increment --from test1 --contract-address $COUNTER_ADDR > /dev/nullaztec-wallet get-tx -d wallet1 transactions:last | grep $LEAFaztec-wallet -d wallet1 simulate get_total --from test1 --contract-address $COUNTER_ADDR | grep -v \\[aztec-wallet -d wallet1 send increment --from test1 --contract-address $COUNTER_ADDR > /dev/nullaztec-wallet get-tx -d wallet1 transactions:last | grep $LEAFaztec-wallet -d wallet1 simulate get_total --from test1 --contract-address $COUNTER_ADDR | grep -v \\[ -
At this point we can identify
incrementtransactions to our contract. Those are the transactions that modify the total count leaf (in the example above,0x2d906bcf3f4298b3a766f6089adaf902690535b6407a6e7fec71d0aa09450d16). Here is how you can check if a specific block has such a transaction (or transactions).Terminal window BLOCK_NUM=53aztec get-block $BLOCK_NUM | grep $LEAF -
Next we need to see if we can figure out what account called
increment.Terminal window cat txn.log | grep -E '===|Leaf|write'The first two transactions, which come from
test0, both have an update to leaf0x22b8cdbf4e6551f3b0d46647f74289cff3c3478a65fcd5e69ab4e0ab0abaaad7. The last two, which come fromtest1, both have an update to leaf0x06866ecba67c37f2badf4560ad48639e51ada748b53d0c0ad8d833d33b5b6e0c.This tells us that while we can’t deduce the account that called
incrementfrom the leaf updated, once we know that a particular leaf belongs to a particular account, we can identify all theincrementcalls done from that account. To verify, we can run the test a few more times.Terminal window aztec-wallet -d wallet1 send increment --from test1 --contract-address $COUNTER_ADDR > /dev/nullaztec-wallet get-tx -d wallet1 transactions:last | grep Leaf | grep -v $LEAFaztec-wallet -d wallet1 send increment --from test1 --contract-address $COUNTER_ADDR > /dev/nullaztec-wallet get-tx -d wallet1 transactions:last | grep Leaf | grep -v $LEAFaztec-wallet -d wallet1 send increment --from test1 --contract-address $COUNTER_ADDR > /dev/nullaztec-wallet get-tx -d wallet1 transactions:last | grep Leaf | grep -v $LEAFThe leaf number we get is consistent.
This doesn’t tell us what calls to
incrementcame fromtest2. But it does tell us that if we can triggertest2to callincrementat a specific time we’ll be able to get that leaf number and use it to find whentest2callsincrementin the future (and when it has called it in the past). Iftest2is used as part of an application we use, we might be able to trigger it.Another potential source of information is that the difference between consecutive values of the per-account leaf is
28083660(in decimal). I do not know of any use for that, but if you can think of something I’d love to hear it.
Conclusion
You now have a working private-per-account counter pattern: notes for per-account private state and an enqueued public call to update shared public state.
For a full application you’ll need a UI and client-side integration using @aztec/aztec.js ↗ to manage wallets, note decryption, and proof submission.