Skip to content

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.

  1. Start the environment, as explained here.

  2. Start the Aztec environment.

    Terminal window
    aztec start --sandbox

    Wait until you see this message:

    [20:06:45.643] INFO: cli Aztec Server listening on port 8080
  3. 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.

  1. Create a new project.

    Terminal window
    aztec-nargo new counter --contract
    cd counter
  2. Replace Nargo.toml with:

    [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.

  3. Replace src/main.nr with:

    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 zero
    storage.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()
    }
    }
  4. Compile the contract.

    Terminal window
    aztec-nargo compile
    aztec-postprocess-contract
  5. Deploy the contract (this is a slow process).

    Terminal window
    aztec-wallet deploy --from test0 target/counter_contract-Counter.json

    Wait 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 0x01e485d313c567508d91c12da16ee9eece6a92bd50925f7ae2a5c162fc60db44
    Contract deployed at 0x029e67317a774f5e22ea5118033b3d32f9a57a6f175bced5098f1b2b257f54cd
    Contract partial address 0x1ba91e31e35c6c54715b2256454e17edb4c9e00ae318b947c9d6468d742af07e
    Contract init hash 0x1b386d6cd70c2fceeed6551dbbd7396725643db5e2d0a1b930a95b10197b1a7e
    Deployment tx hash: 0x01e485d313c567508d91c12da16ee9eece6a92bd50925f7ae2a5c162fc60db44
    Deployment salt: 0x22bb57fd37d0fcad7281709b623433349cf51017f4954493f8ba1b21f1ab7f2a
    Deployment fee: 102185650
    Contract stored in database with alias last
  6. Store the contract address. For example, for the result above, write:

    Terminal window
    COUNTER_ADDR=0x029e67317a774f5e22ea5118033b3d32f9a57a6f175bced5098f1b2b257f54cd

See it in action

  1. 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`
  2. Get the counter value as test0.

    Terminal window
    aztec-wallet simulate get_counter --from test0 --contract-address $COUNTER_ADDR

    See the simulation result is 0n.

  3. Increment the counter.

    Terminal window
    aztec-wallet send increment --from test0 --contract-address $COUNTER_ADDR

    Wait until you see the transaction hash.

    Terminal window
    [23:12:40.041] INFO: pxe:service Sent transaction 0x2a8aad6e000e8627abb866b9a09c670b0c862bd9e0ad628641e43515986689a4

    Set TX_HASH to the transaction hash.

    Terminal window
    TX_HASH=0x2a8aad6e000e8627abb866b9a09c670b0c862bd9e0ad628641e43515986689a4
  4. Get transaction information.

    Terminal window
    aztec get-tx $TX_HASH

    See that the transaction output includes the counter’s new value.

    Public data writes:
    Leaf 0x285c4e3dc6727106db840ce0c96066c8e589f4b705ad0d76139e75694b3e4a40 = 0x0000000000000000000000000000000000000000000000000000000000000003
    Leaf 0x260d11eae90fba8e072dd2cc28bfc8a9f8686ded1c0c2b08121574e634289128 = 0x00000000000000000000000000000000000000000000021e19e0c9baa6f7d434
    Nullifiers:
    Unknown nullifier 0x1c71cfeab39e9a7aecf6198ac6901edbbd9faee1ae95804ad07677caeccaa0bb
    Unknown nullifier 0x199d5c70ae1c74dbc9017205c14c1293eca6acd9162adf01063ff1e555a66275
  5. 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

  1. Create a new directory and move to it.

    Terminal window
    mkdir secret-counter
    cd secret-counter
    mkdir src
  2. Create a Nargo.toml file:

    [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.

  3. Create an src/main.nr file:

    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 call
    SecretCounter::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

  1. 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-accounts
    aztec-wallet -d wallet2 create-account --register-only -sk 0x0f6addf0da06c33293df974a565b03d1ab096090d907d98055a8b7f4954e120c -a test2

    Note that wallet2 only contains the test2 user.

  2. Compile and deploy the contract.

    Terminal window
    aztec-nargo compile && aztec-postprocess-contract
    aztec-wallet deploy -d wallet1 --from test0 target/secret_counter-SecretCounter.json
    COUNTER_ADDR=`aztec-wallet -d wallet1 get-alias contracts:last | head -1`
    echo Counter contract address: $COUNTER_ADDR
  3. Register the contract on wallet2 so 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
  4. Increment the counter for test1 on wallet1.

    Terminal window
    aztec-wallet -d wallet1 send increment --from test1 --contract-address $COUNTER_ADDR
  5. See that using wallet1 we can see the counter for test1 was incremented, even as test2.

    Terminal window
    aztec-wallet -d wallet1 simulate get_counter --from test2 --contract-address $COUNTER_ADDR --args $TEST1
  6. 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
  7. See that on wallet2, which does not have the test1’s private key, you see the counter as zero

    Terminal window
    aztec-wallet -d wallet2 simulate get_counter --from test2 --contract-address $COUNTER_ADDR --args $TEST1
  8. 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 test1
    aztec-wallet -d wallet3 register-contract $COUNTER_ADDR target/secret_counter-SecretCounter.json --from test1 --alias $COUNTER_ADDR
    aztec-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.

  1. Have test0 and test1 increment 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_ADDR
    aztec-wallet get-tx -d wallet1 transactions:last > txn.log
    echo ======= >> txn.log
    aztec-wallet -d wallet1 send increment --from test0 --contract-address $COUNTER_ADDR
    aztec-wallet get-tx -d wallet1 transactions:last >> txn.log
    echo ======= >> txn.log
    aztec-wallet -d wallet1 send increment --from test1 --contract-address $COUNTER_ADDR
    aztec-wallet get-tx -d wallet1 transactions:last >> txn.log
    echo ======= >> txn.log
    aztec-wallet -d wallet1 send increment --from test1 --contract-address $COUNTER_ADDR
    aztec-wallet get-tx -d wallet1 transactions:last >> txn.log
    cat txn.log | grep -v \\[ > txn2.log ; mv txn2.log txn.log
  2. 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 = 0x0000000000000000000000000000000000000000000000000000000000000002
    Leaf 0x22b8cdbf4e6551f3b0d46647f74289cff3c3478a65fcd5e69ab4e0ab0abaaad7 = 0x00000000000000000000000000000000000000000000021e19e0c9b822d91b38
    =======
    Public data writes:
    Leaf 0x2d906bcf3f4298b3a766f6089adaf902690535b6407a6e7fec71d0aa09450d16 = 0x0000000000000000000000000000000000000000000000000000000000000003
    Leaf 0x22b8cdbf4e6551f3b0d46647f74289cff3c3478a65fcd5e69ab4e0ab0abaaad7 = 0x00000000000000000000000000000000000000000000021e19e0c9b8212c956c
    =======
    Public data writes:
    Leaf 0x2d906bcf3f4298b3a766f6089adaf902690535b6407a6e7fec71d0aa09450d16 = 0x0000000000000000000000000000000000000000000000000000000000000004
    Leaf 0x06866ecba67c37f2badf4560ad48639e51ada748b53d0c0ad8d833d33b5b6e0c = 0x00000000000000000000000000000000000000000000021e19e0c9ba8d797bd4
    =======
    Public data writes:
    Leaf 0x2d906bcf3f4298b3a766f6089adaf902690535b6407a6e7fec71d0aa09450d16 = 0x0000000000000000000000000000000000000000000000000000000000000005
    Leaf 0x06866ecba67c37f2badf4560ad48639e51ada748b53d0c0ad8d833d33b5b6e0c = 0x00000000000000000000000000000000000000000000021e19e0c9ba8bccf608
  3. It looks like any time we call increment the data in leaf 0x2d906bcf3f4298b3a766f6089adaf902690535b6407a6e7fec71d0aa09450d16 gets 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 from get_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/null
    aztec-wallet get-tx -d wallet1 transactions:last | grep $LEAF
    aztec-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/null
    aztec-wallet get-tx -d wallet1 transactions:last | grep $LEAF
    aztec-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/null
    aztec-wallet get-tx -d wallet1 transactions:last | grep $LEAF
    aztec-wallet -d wallet1 simulate get_total --from test1 --contract-address $COUNTER_ADDR | grep -v \\[
  4. At this point we can identify increment transactions 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=53
    aztec get-block $BLOCK_NUM | grep $LEAF
  5. 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 leaf 0x22b8cdbf4e6551f3b0d46647f74289cff3c3478a65fcd5e69ab4e0ab0abaaad7. The last two, which come from test1, both have an update to leaf 0x06866ecba67c37f2badf4560ad48639e51ada748b53d0c0ad8d833d33b5b6e0c.

    This tells us that while we can’t deduce the account that called increment from the leaf updated, once we know that a particular leaf belongs to a particular account, we can identify all the increment calls 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/null
    aztec-wallet get-tx -d wallet1 transactions:last | grep Leaf | grep -v $LEAF
    aztec-wallet -d wallet1 send increment --from test1 --contract-address $COUNTER_ADDR > /dev/null
    aztec-wallet get-tx -d wallet1 transactions:last | grep Leaf | grep -v $LEAF
    aztec-wallet -d wallet1 send increment --from test1 --contract-address $COUNTER_ADDR > /dev/null
    aztec-wallet get-tx -d wallet1 transactions:last | grep Leaf | grep -v $LEAF

    The leaf number we get is consistent.

    This doesn’t tell us what calls to increment came from test2. But it does tell us that if we can trigger test2 to call increment at a specific time we’ll be able to get that leaf number and use it to find when test2 calls increment in the future (and when it has called it in the past). If test2 is 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.