Common Patterns
There are many common patterns have been devised by the Aztec core engineering team and the work of the external community as we build Aztec.nr contracts internally (see some of them here (GitHub link)).
This doc aims to summarize some of them!
Similarly we have discovered some anti-patterns too (like privacy leakage) that we will point out here!
Common Patterns
Approving another user/contract to execute an action on your behalf
We call this the "authentication witness" pattern or authwit for short.
- Approve someone in private domain:
// 4. Give approval to bridge to burn owner's funds:
const withdrawAmount = 9n;
const nonce = Fr.random();
await user1Wallet.createAuthWit({
caller: l2Bridge.address,
action: l2Token.methods.burn_private(ownerAddress, withdrawAmount, nonce),
});
Source code: yarn-project/end-to-end/src/e2e_cross_chain_messaging/token_bridge_private.test.ts#L69-L77
Here you approve a contract to burn funds on your behalf.
- Approve in public domain:
const action = asset
.withWallet(wallets[1])
.methods.transfer_in_public(accounts[0].address, accounts[1].address, amount, nonce);
await wallets[0].setPublicAuthWit({ caller: accounts[1].address, action }, true).send().wait();
Source code: yarn-project/end-to-end/src/e2e_token_contract/transfer_in_public.test.ts#L69-L75
Here you approve someone to transfer funds publicly on your behalf
Prevent the same user flow from happening twice using nullifiers
E.g. you don't want a user to subscribe once they have subscribed already. Or you don't want them to vote twice once they have done that. How do you prevent this?
Emit a nullifier in your function. By adding this nullifier into the tree, you prevent another nullifier from being added again. This is also why in authwit, we emit a nullifier, to prevent someone from reusing their approval.
pub fn verify_private_authwit(self, inner_hash: Field) -> Field {
// The `inner_hash` is "siloed" with the `msg_sender` to ensure that only it can
// consume the message.
// This ensures that contracts cannot consume messages that are not intended for them.
let message_hash = compute_authwit_message_hash(
self.context.msg_sender(),
self.context.chain_id(),
self.context.version(),
inner_hash,
);
let valid_fn = self.is_valid_impl;
assert(valid_fn(self.context, message_hash) == true, "Message not authorized by account");
IS_VALID_SELECTOR
}
Source code: noir-projects/aztec-nr/authwit/src/account.nr#L68-L83
Note be careful to ensure that the nullifier is not deterministic and that no one could do a preimage analysis attack. More in the anti pattern section on deterministic nullifiers
Note - you could also create a note and send it to the user. The problem is there is nothing stopping the user from not presenting this note when they next interact with the function.
Reading public storage in private
You can't read public storage in private domain. But nevertheless reading public storage is desirable. There are two ways to achieve the desired effect:
-
For public values that change infrequently, you can use shared state.
-
You pass the data as a parameter to your private method and later assert in public that the data is correct. E.g.:
#[storage]
struct Storage {
token: PublicMutable<Field>,
}
contract Bridge {
#[private]
fn burn_token_private(
token: AztecAddress, // pass token here since this is a private method but can't access public storage
amount: Field,
) -> Field {
...
// Assert that user provided token address is same as seen in storage.
TokenBridge::at(context.this_address())._assert_token_is_same(token).enqueue(&mut context);
}
#[public]
#[internal]
fn _assert_token_is_same(token: AztecAddress) {
assert(storage.token.read().eq(token), "Token address is not the same as seen in storage");
}
}
This leaks information about the private function being called and the data which has been read.
Writing public storage from private
When calling a private function, you can update public state by calling a public function.
In this situation, try to mark the public function as internal
. This ensures your flow works as intended and that no one can call the public function without going through the private function first!
Moving public data into the private domain
See partial notes. Partial notes are how public balances are transferred to private in the token contract.
Discovering my notes
When you send someone a note, the note hash gets added to the note hash tree. To spend the note, the receiver needs to get the note itself (the note hash preimage). There are two ways you can get a hold of your notes:
- When sending someone a note, emit the note contents to the recipient (the function encrypts the log in such a way that only a recipient can decrypt it). PXE then tries to decrypt all the encrypted logs, and stores the successfully decrypted one. More info here
- Manually using
pxe.addNote()
- If you choose to not emit logs to save gas or when creating a note in the public domain and want to consume it in private domain (encrypt_and_emit_note
shouldn't be called in the public domain because everything is public), like in the previous section where we created a note in public that doesn't have a designated owner.
const note = new Note([new Fr(amount), secretHash]);
const extendedNote = new ExtendedNote(
note,
wallet.getAddress(),
asset,
TokenBlacklistContract.storage.pending_shields.slot,
TokenBlacklistContract.notes.TransparentNote.id,
txHash,
);
await wallet.addNote(extendedNote);
Source code: yarn-project/end-to-end/src/composed/e2e_persistence.test.ts#L330-L341
Revealing encrypted logs conditionally
An encrypted log can contain any information for a recipient, typically in the form of a note. One could think this log is emitted as part of the transaction execution, so it wouldn't be revealed if the transaction fails.
This is not true for Aztec, as the encrypted log is part of the transaction object broadcasted to the network. So if a transaction with an encrypted log and a note commitment is broadcasted, there could be a situation where the transaction is not mined or reorg'd out, so the commitment is never added to the note hash tree, but the recipient could still have read the encrypted log from the transaction in the mempool.
Example:
Alice and Bob agree to a trade, where Alice sends Bob a passcode to collect funds from a web2 app, in exchange of on-chain tokens. Alice should only send Bob the passcode if the trade is successful. But just sending the passcode as an encrypted log doesn't work, since Bob could see the encrypted log from the transaction as soon as Alice broadcasts it, decrypt it to get the passcode, and withdraw his tokens from the trade to make the transaction fail.
Randomness in notes
Notes are hashed and stored in the merkle tree. While notes do have a header with a nonce
field that ensure two exact notes still can be added to the note hash tree (since hashes would be different), preimage analysis can be done to reverse-engineer the contents of the note.
Hence, it's necessary to add a "randomness" field to your note to prevent such attacks.
// Stores an address
#[note]
#[derive(Serialize)]
pub struct AddressNote {
address: AztecAddress,
owner: AztecAddress,
randomness: Field,
}
impl NullifiableNote for AddressNote {
fn compute_nullifier(
self,
context: &mut PrivateContext,
note_hash_for_nullify: Field,
) -> Field {
let owner_npk_m_hash = get_public_keys(self.owner).npk_m.hash();
let secret = context.request_nsk_app(owner_npk_m_hash);
poseidon2_hash_with_separator(
[note_hash_for_nullify, secret],
GENERATOR_INDEX__NOTE_NULLIFIER as Field,
)
}
unconstrained fn compute_nullifier_without_context(self) -> Field {
let note_hash_for_nullify = compute_note_hash_for_nullify(self);
let owner_npk_m_hash = get_public_keys(self.owner).npk_m.hash();
let secret = get_nsk_app(owner_npk_m_hash);
poseidon2_hash_with_separator(
[note_hash_for_nullify, secret],
GENERATOR_INDEX__NOTE_NULLIFIER as Field,
)
}
}
impl AddressNote {
pub fn new(address: AztecAddress, owner: AztecAddress) -> Self {
// We use the randomness to preserve the privacy of the note recipient by preventing brute-forcing, so a
// malicious sender could use non-random values to make the note less private. But they already know the full
// note pre-image anyway, and so the recipient already trusts them to not disclose this information. We can
// therefore assume that the sender will cooperate in the random value generation.
let randomness = unsafe { random() };
AddressNote { address, owner, randomness, header: NoteHeader::empty() }
}
Source code: noir-projects/aztec-nr/address-note/src/address_note.nr#L16-L62
L1 -- L2 interactions
Refer to Token Portal codealong tutorial on bridging tokens between L1 and L2 and/or Uniswap smart contract example that shows how to swap on L1 using funds on L2. Both examples show how to:
- L1 -> L2 message flow
- L2 -> L1 message flow
- Cancelling messages from L1 -> L2.
- For both L1->L2 and L2->L1, how to operate in the private and public domain
Sending notes to a contract/Escrowing notes between several parties in a contract
To send a note to someone, they need to have a key which we can encrypt the note with. But often contracts may not have a key. And even if they do, how does it make use of it autonomously?
There are several patterns here:
- Give the contract a key and share it amongst all participants. This leaks privacy, as anyone can see all the notes in the contract.
transfer_to_public
funds into the contract - this is used in the Uniswap smart contract example where a user sends private funds into a Uniswap Portal contract which eventually withdraws to L1 to swap on L1 Uniswap. This works like Ethereum - to achieve contract composability, you move funds into the public domain. This way the contract doesn't even need keys.
There are several other designs we are discussing through in this discourse post but they need some changes in the protocol or in our demo contract. If you are interested in this discussion, please participate in the discourse post!
Share Private Notes
If you have private state that needs to be handled by more than a single user (but no more than a handful), you can add the note commitment to the note hash tree, and then encrypt the note once for each of the users that need to see it. And if any of those users should be able to consume the note, you can generate a random nullifier on creation and store it in the encrypted note, instead of relying on the user secret.
Anti Patterns
There are mistakes one can make to reduce their privacy set and therefore make it trivial to do analysis and link addresses. Some of them are:
Passing along your address when calling a public function from private
If you have a private function which calls a public function, remember that sequencer can see any parameters passed to the public function. So try to not pass any parameter that might leak privacy (e.g. from
address)
PS: when calling from private to public, msg_sender
is the contract address which is calling the public function.
Deterministic nullifiers
In the Prevent the same user flow from happening twice using nullifier, we recommended using nullifiers. But what you put in the nullifier is also as important.
E.g. for a voting contract, if your nullifier simply emits just the user_address
, then privacy can easily be leaked as nullifiers are deterministic (have no randomness), especially if there are few users of the contract. So you need some kind of randomness. You can add the user's secret key into the nullifier to add randomness. We call this "nullifier secrets" as explained here. E.g.:
fn compute_nullifier(
self,
context: &mut PrivateContext,
note_hash_for_nullify: Field,
) -> Field {
let owner_npk_m_hash: Field = get_public_keys(self.owner).npk_m.hash();
let secret = context.request_nsk_app(owner_npk_m_hash);
poseidon2_hash_with_separator(
[note_hash_for_nullify, secret],
GENERATOR_INDEX__NOTE_NULLIFIER as Field,
)
}
Source code: noir-projects/aztec-nr/value-note/src/value_note.nr#L31-L46