> 💡 This branch is primarily aimed towards developers coming from Ethereum and utilizes the sandbox. There is a limited assumed knowledge of zk, but there are pieces throughout to encourage zk devs. We will be adding other branches for different knowledge levels.
In this tutorial, we will write, compile, deploy, and interact with an Aztec.nr smart contract. You do not need any experience with Aztec or Noir, but it will help to have some basic blockchain knowledge. You’ll learn how to:
- Set up a new Aztec.nr project with Nargo
- Write a private transferrable token contract
- Program privacy into Aztec smart contracts in general
- Deploy your contract using Aztec.js
- Interact with your contract using Aztec.js
Before following this tutorial, please make sure you have installed the sandbox.
This tutorial is divided into two parts - the contract and the node app. If you’d like to skip to the Aztec.js part, you can find the full smart contract here.
Run the sandbox using either Docker or npm.
Docker
/bin/bash -c "$(curl -fsSL 'https://sandbox.aztec.network')"
npm
npx @aztec/aztec-sandbox
You will need to install nargo, the Noir build too. if you are familiar with Rust, this is similar to cargo.
curl -L https://raw.githubusercontent.com/noir-lang/noirup/main/install | bash
noirup -v aztec
This command ensures that you are on the aztec
version of noirup, which is what we need to compile and deploy aztec.nr smart contracts.
Create a new directory called aztec-private-token
mkdir aztec-private-token
then create a contracts
folder inside where our aztec.nr contract will live:
cd aztec-private-token
mkdir contracts
Inside contracts
, create a new Noir project using nargo:
cd contracts
nargo new private_token_contract
Your file structure should look like this:
aztec-private-token
|-contracts
| |--private_token_contract
| | |--src
| | | |--main.nr
| | |Nargo.toml
The file main.nr
will soon turn into our smart contract!
Go to the generated file Nargo.toml
and replace it with this:
[package]
name = "private_token"
type = "contract"
authors = [""]
compiler_version = "0.11.1"
[dependencies]
aztec = { git="https://github.com/AztecProtocol/aztec-packages/", tag="master", directory="yarn-project/aztec-nr/aztec" }
value_note = { git="https://github.com/AztecProtocol/aztec-packages/", tag="master", directory="yarn-project/aztec-nr/value-note"}
easy_private_state = { git="https://github.com/AztecProtocol/aztec-packages/", tag="master", directory="yarn-project/aztec-nr/easy-private-state"}
This the type as contract
and adds the dependencies we need to create a private token smart contract.
In this section, we will learn how to write a private transferrable token smart contract.
In this contract, the identity of the sender and recipient, the amount being transferred, and the initial supply of tokens are kept private and only disclosed to the parties involved.
Go to main.nr
and replace the code with this contract and functions:
contract PrivateToken {
#[aztec(private)]
fn constructor(initial_supply: Field, owner: Field) {}
#[aztec(private)]
fn mint(amount: Field, owner: Field) {}
#[aztec(private)]
fn transfer(amount: Field, recipient: Field) {}
unconstrained fn getBalance(owner: Field) -> Field {
0
}
}
This code defines a contract called PrivateToken
with four functions that we will implement later - a constructor
which is called when the contract is deployed, mint
, transfer
, and getBalance
.
We have annotated the functions with #[aztec(private)]
which are ABI macros so the compiler understands it will handle private inputs.
The getBalance
function doesn’t need this as it will only be reading from the chain, not updating state, similar to a view
function in Solidity. This is what unconstrained
means.
In this step, we will initiate a Storage
struct to store balances in a private way. Write this within your contract at the top.
use dep::std::option::Option;
use dep::value_note::{
balance_utils,
utils::{increment, decrement},
value_note::{VALUE_NOTE_LEN, ValueNote, ValueNoteMethods},
};
use dep::aztec::{
context::{PrivateContext, PublicContext, Context},
note::{
note_header::NoteHeader,
utils as note_utils,
},
state_vars::{map::Map, set::Set},
};
struct Storage {
// maps an aztec address to its balance
balances: Map<Set<ValueNote, VALUE_NOTE_LEN>>,
}
// rest of the functions
What are these new dependencies?
context::{PrivateContext, Context}
Context gives us access to the environment information such as msg.sender
. We are also importing PrivateContext
to access necessary information for our private functions. We’ll be using it in the next step.
state_vars::{map::Map, set::Set}
Map is a state variable that functions like a dictionary, relating Fields to other state variables. A Set is specifically used for managing multiple notes.
value_note::{VALUE_NOTE_LEN, ValueNote, ValueNoteMethods}
Notes are fundamental to how Aztec manages privacy. A note is a privacy-preserving representation of an amount of tokens associated with an address, while encrypting the amount and owner. In this contract, we are using the value_note
library.
From the value_note
library we are using ValueNote
which is a type of note interface for storing a single Field, eg a balance, VALUE_NOTE_LEN
which is a global const of 3 acting as the length of a ValueNote, and ValueNoteMethods
which is a collection of functions for operating on a ValueNote.
Now we’ve got that out of the way, let’s create an init method for our Storage struct:
impl Storage {
fn init(context: Context) -> pub Self {
Storage {
balances: Map::new(
context,
1, // Storage slot
|context, slot| {
Set::new(context, slot, ValueNoteMethods)
},
),
}
}
}
This init
method is creating and initializing a Storage
instance. This instance includes a Map
named balances
. Each entry in this Map
represents an account's balance.
When the Map
is created, it is populated with a Set
of ValueNote
for each slot (representing each address). The Set
contains all ValueNote
entries (private balances) corresponding to that address. The init
method uses the given Context
to correctly set up this initial state.
Now we’ve got a mechanism for storing our private state, we can start using it to ensure the privacy of balances.
Let’s create a constructor
method to run on deployment that assigns an initial supply of tokens to a specified owner. In the constructor we created in the first step, write this:
#[aztec(private)]
fn constructor(
initial_supply: Field,
owner: Field
) {
let storage = Storage::init(Context::private(&mut context)); // Initialize Storage struct with the private context
let owner_balance = storage.balances.at(owner); // Access the Set of the owner's ValueNotes from the "balances" Map
if (initial_supply != 0) {
increment(owner_balance, initial_supply, owner); // Increase owner's supply by specified amount
}
}
Here, we are creating a private context and using this to initialize the storage struct. The function then accesses the encrypted balance of the owner from storage. Lastly, it assigns the initial supply of tokens to the owner, maintaining the privacy of the operation by working on encrypted data.
Now let’s implement the transfer
and mint
function we defined in the first step. In the mint
function, write this:
#[aztec(private)]
fn mint(
amount: Field,
owner: Field
) {
let storage = Storage::init(Context::private(&mut context));
let owner_balance = storage.balances.at(owner);
increment(owner_balance, amount, owner);
}
In the mint function, we first transform our context into a private one and initialize our storage as we did in the constructor. We then access the owner's ValueNote
Set from the balances
Map. We then use this to increment the owner
's balance using the balance_utils::increment
function to add the minted amount to the owner's balance privately.
The transfer
function is similar. In the transfer
function, put this:
#[aztec(private)]
fn transfer(
amount: Field,
recipient: Field,
) {
let storage = Storage::init(Context::private(&mut context));
let sender = context.msg_sender(); // set sender as msg.sender()
let sender_balance = storage.balances.at(sender); // get the sender's balance
decrement(sender_balance, amount, sender); // decrement sender balance by amount
// Creates new note for the recipient.
let recipient_balance = storage.balances.at(recipient); // get recipient's balance
increment(recipient_balance, amount, recipient); // increment recipient balance by amount
}
Here, we create a private context, initialize the storage, and set the sender as msg.sender()
. We then get the sender’s balance, decrement it by the amount specified, and increment the recipient’s balance in the same way.
Because our token transfers are private, the network can't directly verify if a note was spent or not, which could lead to double-spending. To solve this, we use a nullifier - a unique identifier generated from each spent note and its owner.
Add a new function into your contract as shown below:
unconstrained fn compute_note_hash_and_nullifier(contract_address: Field, nonce: Field, storage_slot: Field, preimage: [Field; VALUE_NOTE_LEN]) -> [Field; 4] {
let note_header = NoteHeader { contract_address, nonce, storage_slot };
note_utils::compute_note_hash_and_nullifier(ValueNoteMethods, note_header, preimage)
}
Here, we're computing both the note hash and the nullifier. The nullifier computation uses Aztec’s compute_note_hash_and_nullifier
function, which takes our ValueNoteMethods
and details about the note's attributes eg contract address, nonce, storage slot, and preimage.
Aztec will use these nullifiers to track and prevent double-spending, ensuring the integrity of private transactions without us having to explicitly program a check within smart contract functions.
The last thing we need to implement which will help us test our contract is the getBalance function. in the getBalance
we defined in the first step, write this:
unconstrained fn getBalance(owner: Field) -> Field {
let context = Context::none();
let storage = Storage::init(context);
let owner_notes = storage.balances.at(owner);
balance_utils::get_balance(owner_notes)
}
In this function, we initialize our storage with no context as it is not required. This allows us to fetch data from storage without a transaction. We retrieve a reference to the owner
's ValueNote
Set from the balances
Map. The get_balance
function then operates on the owner's ValueNote
Set. This processes the set of ValueNote
s to yield a private and encrypted balance that only the private key owner can decrypt.
This tutorial assumes you have followed along to create a private token smart contract. If you skipped that part, you can get the smart contract here.
You will need to run the sandbox if it is not running already. You can use either Docker or npm.
Docker
/bin/bash -c "$(curl -fsSL 'https://sandbox.aztec.network')"
npm
npx @aztec/aztec-sandbox
- Aztec Sandbox
- Node ≥ 18
- Aztec CLI
yarn global add @aztec/cli
or
npm install -g @aztec/cli
Go the root directory we created in this section and create a new yarn
project. npm
works too.
yarn init
Leave the following questions as default.
Add typescript
and Aztec libraries to your project:
yarn add typescript @types/node --dev @aztec/aztec.js @aztec/noir-contracts
and create a src
directory:
mkdir src
Now in your package.json
add a scripts
section and set "type":"module"
:
"type": "module",
"scripts": {
"build": "yarn clean && tsc -b",
"build:dev": "tsc -b --watch",
"clean": "rm -rf ./dest tsconfig.tsbuildinfo",
"start": "yarn build && export DEBUG='private-token' && node ./dest/src/index.js"
},
Create a tsconfig.json
in the root and use your favourite config settings. Here’s an example:
{
"compilerOptions": {
"rootDir": "./",
"outDir": "dest",
"target": "es2020",
"lib": ["dom", "esnext", "es2017.object"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"declaration": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"downlevelIteration": true,
"inlineSourceMap": true,
"declarationMap": true,
"importHelpers": true,
"resolveJsonModule": true,
"composite": true,
"skipLibCheck": true
},
"include": ["src/**/*", "contracts/**/*.json"],
"exclude": ["node_modules", "**/*.spec.ts", "contracts/**/*.ts"],
"references": []
}
Now we’re set up!
The Aztec CLI has a compiler that allows you to autogenerate type-safe typescript classes for your contracts.
Generate one for our private token smart contract like this (assuming you are in the project root directory)
aztec-cli compile --typescript ../../src ./contracts/private_token_contract
This will create privateToken.ts
and target
in your src
dir.
Now we’re ready for some code. We’re going to create a deploy script.
Create an index.ts
file in src
and paste this:
import { Fr } from '@aztec/foundation/fields';
import { createAztecRpcClient } from '@aztec/aztec.js';
import { PrivateTokenContract } from './PrivateToken.js'; // the TS file we generated from our smart contract
const SANDBOX_URL = process.env['SANDBOX_URL'] || 'http://localhost:8080';
We will use Fr to create a salt
and createAztecRpcClient
to communicate with the sandbox.
Create a new async function and set up the RPC client.
const deployContract = async () => {
const rpc = await createAztecRpcClient(SANDBOX_URL);
const accounts = await rpc.getAccounts();
console.log(`Accounts: ${await console.log(accounts)})`);
};
This creates an RPC client for us to communicate with the sandbox and gets all existing accounts.
At the end of the file put this so this function is called when we run:
deployContract();
Run yarn start
and you should see something like this:
[
CompleteAddress {
address: AztecAddress {
buffer: <Buffer 0c 8a 66 73 d7 67 6c c8 0a ae be 7f a7 50 4c f5 1d aa 90 ba 90 68 61 bf ad 70 a5 8a 98 bf 5a 7d>
},
publicKey: Point { x: [Fr], y: [Fr], kind: 'point' },
partialAddress: Fr {
value: 12842361594093371645447963466236087693839286598884465802477690293367168135161n
}
},
CompleteAddress {
address: AztecAddress {
buffer: <Buffer 22 6f 80 87 79 2b ef f8 d5 00 9e b9 4e 65 d2 a4 a5 05 b7 0b af 4a 9f 28 d3 3c 8d 62 0b 0b a9 72>
},
publicKey: Point { x: [Fr], y: [Fr], kind: 'point' },
partialAddress: Fr {
value: 10947199389209909230221260693433096752267924726152037777149078870207166136489n
}
},
CompleteAddress {
address: AztecAddress {
buffer: <Buffer 0e 1f 60 e8 56 6e 2c 6d 32 37 8b dc ad b7 c6 36 96 e8 53 28 1b e7 98 c1 07 26 6b 8c 3a 88 ea 9b>
},
publicKey: Point { x: [Fr], y: [Fr], kind: 'point' },
partialAddress: Fr {
value: 21716832730255068406413142798013580191572666867321964226631189064226445259586n
}
}
]
Now under our logging statement let’s get the data we need to deploy:
const deployerWallet = accounts[0];
const salt = Fr.random();
We will use the first account in our array to deploy the contract and a salt to help us compute where the contract will be deployed (like CREATE2).
Next we will create and send a deployment transaction object:
const tx = PrivateTokenContract.deploy(
rpc,
100n,
deployerWallet.address).send(
{ contractAddressSalt: salt });
console.log(`Tx sent with hash ${await tx.getTxHash()}`);
deploy
takes 3 arguments:
- rpc: instance of an RPC client (RPC object)
- noteValue: initial token supply (BigInt)
- deployerAddress: Aztec account address that will deploy the contract (string)
We are also passing contractAddressSalt
in options, with our salt we generated from Fr
.
Run yarn start
and you’ll see something like this:
Tx sent with hash 1a8fc8a8807fd9504869426f9470ca8fc7bc89aa9db53d201ea9765866be46a1
The deploy transaction has been sent - now let’s make sure it is successfully mined.
tx
has a function getReceipt
which contains status, block information, tx hash, and contract address. Put this under your transaction:
const receipt = await tx.getReceipt();
console.log(`Status: ${receipt.status}`);
console.log(`Contract address: ${receipt.contractAddress}`);
If we run this, we will see:
Status: pending
Contract address: undefined
so we have to wait until after the transaction is mined.
Add this line on top of your receipt code:
await tx.isMined({ interval: 0.1 });
The interval
lets us check if the transaction is mined every 0.1 seconds.
Your entire file should look like this:
import { Fr } from '@aztec/foundation/fields';
import { createAztecRpcClient } from '@aztec/aztec.js';
import { PrivateTokenContract } from './PrivateToken.js';
const SANDBOX_URL = process.env['SANDBOX_URL'] || 'http://localhost:8080';
const deployContract = async () => {
const rpc = await createAztecRpcClient(SANDBOX_URL);
const accounts = await rpc.getAccounts();
await console.log(accounts);
const deployerWallet = accounts[0];
const salt = Fr.random();
const tx = PrivateTokenContract.deploy(rpc, 100n, deployerWallet.address).send({ contractAddressSalt: salt });
console.log(`Tx sent with hash ${await tx.getTxHash()}`);
await tx.isMined({ interval: 0.1 });
const receiptAfterMined = await tx.getReceipt();
console.log(`Status: ${receiptAfterMined.status}`);
console.log(`Contract address: ${receiptAfterMined.contractAddress}`);
};
deployContract()
Run yarn start
and you’ll see something like this:
Status: mined
Contract address: 0x05eaa897fb321983b60715f37809f36aec7f6061eaf671574b6c0303bbdd9687
Congratulations! You’ve just written and deployed an Aztec.nr smart contract on the sandbox! 🚀
To learn more about Aztec.js, including how to interact with your new deployed contract, check out the docs here.