Skip to content

Commit c022797

Browse files
author
AztecBot
committed
Merge branch 'next' into merge-train/fairies
2 parents aed4420 + 6e5716b commit c022797

12 files changed

Lines changed: 450 additions & 0 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"position": 10,
3+
"collapsible": true,
4+
"collapsed": true,
5+
"label": "Standards"
6+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
---
2+
title: "AIP-20: Fungible Token"
3+
sidebar_position: 1
4+
description: Fungible token standard with private balances, partial-note transfers, and recursive note consumption.
5+
---
6+
7+
[Source](https://github.com/defi-wonderland/aztec-standards/tree/dev/src/token_contract)
8+
9+
AIP-20 defines a fungible token with support for private balances (stored as notes in the note hash tree), public balances (stored in contract public storage), and a hybrid transfer path between the two.
10+
11+
## Storage layout
12+
13+
The token contract stores its name, symbol, and decimals as immutable public fields. Private balances are held in an `Owned<BalanceSet>` that restricts note access to the balance owner. Public balances use a simple `Map` keyed by address.
14+
15+
```rust
16+
#[storage]
17+
struct Storage<Context> {
18+
name: PublicImmutable<FieldCompressedString, Context>,
19+
symbol: PublicImmutable<FieldCompressedString, Context>,
20+
decimals: PublicImmutable<u8, Context>,
21+
private_balances: Owned<BalanceSet<Context>, Context>,
22+
total_supply: PublicMutable<u128, Context>,
23+
public_balances: Map<AztecAddress, PublicMutable<u128, Context>, Context>,
24+
minter: PublicImmutable<AztecAddress, Context>,
25+
upgrade_authority: PublicImmutable<AztecAddress, Context>,
26+
asset: PublicImmutable<AztecAddress, Context>,
27+
vault_offset: PublicImmutable<u128, Context>,
28+
}
29+
```
30+
31+
The `asset` and `vault_offset` fields exist to support the [AIP-4626 vault pattern](./aip-4626.md). A standalone AIP-20 token that is not used as a vault underlying asset does not need to populate these fields.
32+
33+
## Note count constants
34+
35+
In Aztec, every time a user receives private tokens, a new encrypted note is added to their balance. Over time, a user's balance can be spread across dozens of small notes. A transfer must consume enough of these notes to cover the amount, but each note consumed adds computational overhead (gates) to the zero-knowledge proof the user's device must generate. Without a bound, a single transfer could take minutes to prove. Two constants cap how many notes a single proof handles, keeping proving times practical:
36+
37+
```rust
38+
global INITIAL_TRANSFER_CALL_MAX_NOTES: u32 = 2;
39+
global RECURSIVE_TRANSFER_CALL_MAX_NOTES: u32 = 8;
40+
```
41+
42+
The initial call attempts to settle the transfer with at most two notes. If that is not enough to cover the amount, the contract recurses into itself and tries up to eight notes per recursive call.
43+
44+
A **placeholder address** is used in partial-note flows to signal that a transfer destination is not yet known at the time the sender initiates the operation. This distinguishes "recipient not yet determined" from "recipient is the zero address," and allows offchain indexers to detect [partial-note transfers](#partial-note-transfers) in event logs without decrypting the note contents:
45+
46+
```rust
47+
global PRIVATE_ADDRESS_MAGIC_VALUE: AztecAddress =
48+
AztecAddress::from_field(0x1ea7e01501975545617c2e694d931cb576b691a4a867fed81ebd3264);
49+
```
50+
51+
## Partial-note transfers
52+
53+
AIP-20 supports partial-note (or "commitment-based") transfers. In Aztec, private functions execute on the user's device before the transaction reaches the network, so they cannot read public state (like a DEX order book or auction result). Partial notes solve this by splitting the operation: the sender privately locks funds into a commitment, and later a public function — which *can* read public state — completes the transfer to the correct recipient. This is what makes private DeFi composability possible.
54+
55+
Concretely, the sender locks funds in a note whose destination address is not yet known. A completer — typically a contract acting as a relayer or settlement layer — later fills in the recipient and finalizes the note.
56+
57+
The sender calls `initialize_transfer_commitment` to create the commitment:
58+
59+
```rust
60+
#[external("private")]
61+
fn initialize_transfer_commitment(to: AztecAddress, completer: AztecAddress) -> Field {
62+
let commitment = self.internal._initialize_transfer_commitment(to, completer);
63+
commitment.to_field()
64+
}
65+
```
66+
67+
The returned `Field` is an opaque commitment to the destination and completer. A separate call then subtracts the balance and completes the note:
68+
69+
```rust
70+
#[external("private")]
71+
fn transfer_private_to_commitment(
72+
from: AztecAddress,
73+
commitment: Field,
74+
amount: u128,
75+
_nonce: Field,
76+
) {
77+
_validate_from_private::<4>(self.context, from);
78+
79+
self.internal._decrease_private_balance(from, amount, INITIAL_TRANSFER_CALL_MAX_NOTES);
80+
81+
let completer = self.msg_sender();
82+
PartialUintNote::from_field(commitment).complete_from_private(
83+
self.context,
84+
completer,
85+
amount,
86+
);
87+
}
88+
```
89+
90+
This two-step design is useful in DeFi protocols where the recipient of funds depends on some offchain or asynchronous computation.
91+
92+
## Recursive balance subtraction
93+
94+
When `INITIAL_TRANSFER_CALL_MAX_NOTES` notes are insufficient to cover a transfer, the contract calls itself recursively until the full amount is consumed:
95+
96+
```rust
97+
#[internal("private")]
98+
fn _subtract_balance(account: AztecAddress, amount: u128, max_notes: u32) -> u128 {
99+
let subtracted = self.storage.private_balances.at(account).try_sub(amount, max_notes);
100+
if subtracted >= amount {
101+
subtracted - amount
102+
} else {
103+
assert(subtracted > 0, "Balance too low");
104+
let remaining = amount - subtracted;
105+
self.call_self.recurse_subtract_balance_internal(account, remaining)
106+
}
107+
}
108+
```
109+
110+
The recursion terminates when either the full amount has been deducted or the assertion fires.
111+
112+
Without recursion, you would have to either size every circuit for the worst-case note count (making the common case expensive to prove) or fail transfers when the note count exceeds a fixed limit. Recursion gives the best of both worlds: the common case (2 notes) proves fast, while larger balances are handled by chaining multiple smaller proofs. Each recursive call is a separate private kernel circuit, so proving cost scales with the actual note count rather than the worst case.
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
---
2+
title: "AIP-4626: Tokenized Vault"
3+
sidebar_position: 3
4+
description: Yield-bearing vault standard with share conversion across private and public contexts.
5+
---
6+
7+
[Source](https://github.com/defi-wonderland/aztec-standards/tree/dev/src/token_contract) (extends the AIP-20 token contract)
8+
9+
AIP-4626 extends [AIP-20](./aip-20.md) to describe a tokenized vault: a contract that holds an underlying asset and issues shares representing a proportional claim on that asset. It mirrors the design of ERC-4626 but adapts the share conversion arithmetic for Aztec's `u128` integer type.
10+
11+
## Share conversion
12+
13+
The vault tracks the total supply of shares and a `vault_offset` that prevents inflation attacks on the initial deposit. The conversion functions use integer arithmetic with configurable rounding direction:
14+
15+
```rust
16+
#[internal("public")]
17+
fn _convert_to_shares(assets: u128, total_assets: u128, rounding: bool) -> u128 {
18+
let mul_term =
19+
assets * (self.storage.total_supply.read() + self.storage.vault_offset.read());
20+
let denominator = (total_assets + 1);
21+
let mut shares = mul_term / denominator;
22+
if (rounding == ROUND_UP) & (mul_term % denominator > 0) {
23+
shares = shares + 1;
24+
}
25+
shares
26+
}
27+
28+
#[internal("public")]
29+
fn _convert_to_assets(shares: u128, total_assets: u128, rounding: bool) -> u128 {
30+
let mul_term = shares * (total_assets + 1);
31+
let denominator = (self.storage.total_supply.read() + self.storage.vault_offset.read());
32+
let mut assets = mul_term / denominator;
33+
if (rounding == ROUND_UP) & (mul_term % denominator > 0) {
34+
assets = assets + 1;
35+
}
36+
assets
37+
}
38+
```
39+
40+
The `+ 1` in the denominator and the `vault_offset` together implement the "virtual shares" technique that prevents the first depositor from manipulating the exchange rate for subsequent depositors. Without this protection, an attacker could deposit 1 wei, then donate a large amount of the underlying asset directly to the vault, inflating the share price so that the next depositor's deposit rounds down to zero shares.
41+
42+
Deposits round shares down (in favor of the vault), while redemptions round assets down (also in favor of the vault). This is consistent with ERC-4626 rounding conventions and prevents rounding-based extraction attacks.
43+
44+
## Deposit flow
45+
46+
A public-to-public deposit transfers assets from the caller to the vault, computes the shares due, and mints them to the recipient:
47+
48+
```rust
49+
#[external("public")]
50+
fn deposit_public_to_public(from: AztecAddress, to: AztecAddress, assets: u128, _nonce: Field) {
51+
self.internal._validate_from_public(from);
52+
53+
let total_assets = self.internal._total_assets();
54+
let shares = self.internal._convert_to_shares(assets, total_assets, ROUND_DOWN);
55+
56+
// Transfer assets from sender to vault
57+
self.call(Token::at(self.storage.asset.read()).transfer_public_to_public(
58+
from, self.address, assets, _nonce,
59+
));
60+
61+
// Mint shares to the recipient
62+
self.internal._mint_to_public(to, shares);
63+
}
64+
```
65+
66+
The vault exposes similar entry points for the other combinations of private and public contexts (`deposit_private_to_public`, `deposit_public_to_private`, `deposit_private_to_private`). Each variant transfers assets using the corresponding AIP-20 transfer function and then mints shares into the chosen output context.
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
title: "AIP-721: Non-Fungible Token"
3+
sidebar_position: 2
4+
description: Non-fungible token standard with private ownership, partial-note support, and commitment-based transfers.
5+
---
6+
7+
[Source](https://github.com/defi-wonderland/aztec-standards/tree/dev/src/nft_contract)
8+
9+
AIP-721 defines a non-fungible token (NFT). Each token is identified by a unique `token_id` field. Tokens can be held privately in the note hash tree or publicly in a map from `token_id` to owner address.
10+
11+
## Storage layout
12+
13+
```rust
14+
#[storage]
15+
struct Storage<Context> {
16+
symbol: PublicImmutable<FieldCompressedString, Context>,
17+
name: PublicImmutable<FieldCompressedString, Context>,
18+
private_nfts: Owned<PrivateSet<NFTNote, Context>, Context>,
19+
nft_exists: Map<Field, PublicMutable<bool, Context>, Context>,
20+
public_owners: Map<Field, PublicMutable<AztecAddress, Context>, Context>,
21+
minter: PublicImmutable<AztecAddress, Context>,
22+
upgrade_authority: PublicImmutable<AztecAddress, Context>,
23+
}
24+
```
25+
26+
`nft_exists` tracks whether a given `token_id` has been minted, while `public_owners` records the current public owner. When an NFT is moved to a private note, the `public_owners` entry is cleared and the NFT is stored as an `NFTNote` in the holder's private set.
27+
28+
## NFTNote and partial-note support
29+
30+
Each private NFT is represented as an `NFTNote` containing only the `token_id`:
31+
32+
```rust
33+
#[custom_note]
34+
pub struct NFTNote {
35+
pub token_id: Field,
36+
}
37+
38+
impl NFTNote {
39+
pub fn partial(
40+
owner: AztecAddress,
41+
storage_slot: Field,
42+
context: &mut PrivateContext,
43+
recipient: AztecAddress,
44+
completer: AztecAddress,
45+
) -> PartialNFTNote {
46+
let randomness = unsafe { random() };
47+
let commitment = compute_partial_commitment(owner, storage_slot, randomness);
48+
// ... creates encrypted log and validity commitment
49+
let partial_note = PartialNFTNote { commitment };
50+
let validity_commitment = partial_note.compute_validity_commitment(completer);
51+
context.push_nullifier(validity_commitment);
52+
partial_note
53+
}
54+
}
55+
```
56+
57+
The `partial` constructor creates a `PartialNFTNote` whose `commitment` field commits to the future owner and storage slot. Without some form of access control, any party could call the completion function and claim the NFT for themselves. The validity commitment prevents this — it is pushed as a nullifier, and only the designated completer can produce the matching preimage needed to finalize the note. This mirrors the partial-note pattern in [AIP-20](./aip-20.md) but applies it to NFT transfers.
58+
59+
## Partial-note transfer commitment
60+
61+
The external entry point for initiating a partial NFT transfer is:
62+
63+
```rust
64+
#[external("private")]
65+
fn initialize_transfer_commitment(to: AztecAddress, completer: AztecAddress) -> Field {
66+
let commitment = self.internal._initialize_transfer_commitment(to, completer);
67+
commitment.commitment()
68+
}
69+
```
70+
71+
This function returns `commitment.commitment()` (the raw commitment field), whereas the AIP-20 equivalent returns `commitment.to_field()`. The difference reflects the distinct internal types — `PartialNFTNote` vs `PartialUintNote` — but both return an opaque `Field` to the caller.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
title: Dripper (Development Faucet)
3+
sidebar_position: 6
4+
description: Convenience faucet for minting tokens into private or public balances during development.
5+
---
6+
7+
[Source](https://github.com/defi-wonderland/aztec-standards/tree/dev/src/dripper)
8+
9+
The `aztec-standards` repository also ships a **Dripper** contract — a convenience faucet for minting tokens into private or public balances during development. It is not a formal AIP standard and should not be used in production.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
---
2+
title: Escrow
3+
sidebar_position: 4
4+
description: Minimal token and NFT custody contract with salt-based authorization.
5+
---
6+
7+
[Source](https://github.com/defi-wonderland/aztec-standards/tree/dev/src/escrow_contract)
8+
9+
The Escrow standard provides a minimal contract for holding tokens or NFTs on behalf of a single owner. Rather than storing the owner in mutable private state — which would require note discovery and decryption on every authorization check — the owner is encoded in the contract's own `salt` and thus baked into the contract address at deploy time. This makes authorization a simple field comparison against immutable deployment parameters: cheaper, simpler, and impossible to front-run.
10+
11+
## Escrow contract
12+
13+
```rust
14+
#[aztec]
15+
pub contract Escrow {
16+
#[external("private")]
17+
fn withdraw(token: AztecAddress, amount: u128, recipient: AztecAddress) {
18+
self.internal._assert_msg_sender();
19+
self.call(Token::at(token).transfer_private_to_private(
20+
self.address, recipient, amount, 0,
21+
));
22+
}
23+
24+
#[external("private")]
25+
fn withdraw_nft(nft: AztecAddress, token_id: Field, recipient: AztecAddress) {
26+
self.internal._assert_msg_sender();
27+
self.call(NFT::at(nft).transfer_private_to_private(
28+
self.address, recipient, token_id, 0,
29+
));
30+
}
31+
32+
#[internal("private")]
33+
fn _assert_msg_sender() {
34+
let msg_sender = self.msg_sender();
35+
let escrow_instance: ContractInstance = get_contract_instance(self.address);
36+
assert(AztecAddress::from_field(escrow_instance.salt) == msg_sender, "Not Authorized");
37+
}
38+
}
39+
```
40+
41+
The authorization check in `_assert_msg_sender` reads the `salt` field of the escrow's own `ContractInstance` and compares it against `msg_sender`. Because the `ContractInstance` is fixed at deployment time, this check cannot be spoofed by manipulating storage after deployment.
42+
43+
## Escrow logic library
44+
45+
A DeFi protocol (like a lending market or DEX) often needs to give each user a personal escrow to hold collateral or pending settlements. The standard ships a companion library that lets the parent contract deterministically compute escrow addresses from its own address and the user's keys — no onchain deployment transaction required:
46+
47+
```rust
48+
#[contract_library_method]
49+
pub fn _get_escrow(
50+
context: &mut PrivateContext,
51+
escrow_class_id: Field,
52+
master_secret_keys: MasterSecretKeys,
53+
) -> AztecAddress {
54+
let computed_public_keys: PublicKeys = _secret_keys_to_public_keys(master_secret_keys);
55+
let escrow_instance = ContractInstance {
56+
salt: context.this_address().to_field(),
57+
deployer: AztecAddress::from_field(0),
58+
contract_class_id: ContractClassId::from_field(escrow_class_id),
59+
initialization_hash: 0,
60+
public_keys: computed_public_keys,
61+
};
62+
escrow_instance.to_address()
63+
}
64+
65+
#[contract_library_method]
66+
pub fn _share_escrow(
67+
context: &mut PrivateContext,
68+
account: AztecAddress,
69+
escrow: AztecAddress,
70+
master_secret_keys: MasterSecretKeys,
71+
) {
72+
let event_struct = EscrowDetailsLogContent { escrow, master_secret_keys };
73+
emit_event_in_private(context, event_struct).deliver_to(
74+
account, MessageDelivery.ONCHAIN_CONSTRAINED,
75+
);
76+
}
77+
```
78+
79+
`_get_escrow` reconstructs the escrow address deterministically from the calling contract's address (used as the salt) and a set of master secret keys. `_share_escrow` emits an encrypted log so that the designated `account` can discover the escrow address and the keys needed to access its notes. Without this notification, the user's PXE would have no way to find the escrow or decrypt notes held there. The `ONCHAIN_CONSTRAINED` delivery mode ensures the log is validated against the note hash tree before the recipient's PXE trusts it.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
title: Generic Proxy
3+
sidebar_position: 5
4+
description: Forwarding layer for account abstraction that routes calls by argument count.
5+
---
6+
7+
In Aztec, account contracts authorize every transaction the user sends and must be able to forward calls to any contract. However, Noir requires function signatures to be known at compile time, so an account contract cannot call an arbitrary function with an arbitrary number of arguments in a single generic entrypoint.
8+
9+
The Generic Proxy contract solves this by providing a fixed set of forwarding functions — one per argument count — that the account contract can call. This avoids hard-coding every possible target function signature while keeping the account contract simple.
10+
11+
```rust
12+
#[aztec]
13+
pub contract GenericProxy {
14+
#[external("private")]
15+
fn forward_private_0(target: AztecAddress, selector: FunctionSelector) {
16+
let _ = self.context.call_private_function_no_args(target, selector);
17+
}
18+
19+
#[external("private")]
20+
fn forward_private_4(target: AztecAddress, selector: FunctionSelector, args: [Field; 4]) {
21+
let _ = self.context.call_private_function(target, selector, args);
22+
}
23+
24+
#[external("private")]
25+
fn forward_private_4_and_return(
26+
target: AztecAddress,
27+
selector: FunctionSelector,
28+
args: [Field; 4],
29+
) -> Field {
30+
let returns: Field =
31+
self.context.call_private_function(target, selector, args).get_preimage();
32+
returns
33+
}
34+
// ... forward_private_1 through forward_private_8
35+
}
36+
```
37+
38+
The proxy exposes a family of `forward_private_N` functions, each accepting a different fixed argument count. Because Noir's type system requires array lengths to be known at compile time, the contract implements one overload per arity rather than a single variadic function. The `_and_return` variant captures the return value from the callee and passes it back to the caller.
39+
40+
:::note
41+
The Generic Proxy does not implement any access control by itself. Callers are responsible for ensuring that forwarding to `target` is appropriate. In most protocols, the proxy is called from within an account contract that enforces its own authorization rules before delegating to the proxy.
42+
:::

0 commit comments

Comments
 (0)