For the complete documentation index, see llms.txt.
Skip to main content
Version: Tierra (v6.0.0)

AIP-4626: Tokenized Vault

Source (uses AIP-20 tokens for the asset and the shares)

AIP-4626 describes a tokenized vault: a contract that holds an underlying asset and issues shares representing a proportional claim on that asset. In the aztec-standards implementation, the vault is a standalone contract and the shares are a separate AIP-20 token whose minter is the vault. The design is inspired by ERC-4626, but the separate share token makes it closer to ERC-7575, and it adapts the share conversion arithmetic for Aztec's u128 integer type.

warning

The aztec-standards repository marks the vault as not production-ready. Its ordering-based reentrancy protection does not hold when the asset token or the share token has an ARC-403 authorization hook configured, and its conversions stop working near a u128 capacity ceiling. Read the vault README before wrapping a token.

Share conversion​

The vault reads the total supply from the share token and keeps a vault_offset that prevents inflation attacks on the initial deposit. The conversion functions take both as arguments, use integer arithmetic with a configurable rounding direction, and compute the intermediate product in 256 bits so it does not overflow u128:

/// @notice Converts an amount of assets to shares using the given exchange rate parameters
/// @param assets The amount of assets to convert
/// @param total_assets The total amount of assets in the vault
/// @param total_supply The total supply of shares
/// @param vault_offset The vault offset
/// @param rounding The rounding direction (ROUND_UP or ROUND_DOWN)
/// @return The equivalent amount of shares
#[contract_library_method]
fn _convert_to_shares(
assets: u128,
total_assets: u128,
total_supply: u128,
vault_offset: u128,
rounding: bool,
) -> u128 {
// `assets * (total_supply + vault_offset)` overflows u128 for large inputs; compute it in a
// 256-bit intermediate. Rounding is unchanged (see `conversion::mul_div`).
crate::conversion::mul_div(
assets,
total_supply + vault_offset,
total_assets + 1,
rounding == ROUND_UP,
)
}

/// @notice Converts an amount of shares to assets using the given exchange rate parameters
/// @param shares The amount of shares to convert
/// @param total_assets The total amount of assets in the vault
/// @param total_supply The total supply of shares
/// @param vault_offset The vault offset
/// @param rounding The rounding direction (ROUND_UP or ROUND_DOWN)
/// @return The equivalent amount of assets
#[contract_library_method]
fn _convert_to_assets(
shares: u128,
total_assets: u128,
total_supply: u128,
vault_offset: u128,
rounding: bool,
) -> u128 {
// `shares * (total_assets + 1)` overflows u128 for large inputs; compute it in a 256-bit
// intermediate. Rounding is unchanged (see `conversion::mul_div`).
crate::conversion::mul_div(
shares,
total_assets + 1,
total_supply + vault_offset,
rounding == ROUND_UP,
)
}

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.

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.

Deposit flow​

A public-to-public deposit computes the shares due, takes the assets from from, and has the share token mint the shares to the recipient:

#[external("public")]
fn deposit_public_to_public(from: AztecAddress, to: AztecAddress, assets: u128, nonce: Field) {
let asset_token = self.storage.asset.read();
let shares_token = self.storage.shares.read();
self.internal._validate_from_public(from);

// Calculate shares to mint
let total_assets = self.internal._total_assets();
let total_supply = self.internal._total_supply_of_shares();
let vault_offset = self.storage.vault_offset.read();
let shares =
_convert_to_shares(assets, total_assets, total_supply, vault_offset, ROUND_DOWN);
assert(shares > 0, "Zero shares, insufficient assets");

// Order matters: transfer before minting to order effects safely (necessary but NOT sufficient against a hooked token — see the ARC-403 reentrancy note at the top of this contract).
// Take the assets from the sender
self.call(Token::at(asset_token).transfer_public_to_public(
from,
self.address,
assets,
nonce,
));

// Mint shares to the recipient
self.call(Token::at(shares_token).mint_to_public(to, shares));
}

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). The variants that mint shares into a private balance take the number of shares as an argument and check in public that assets covers it, since the exchange rate is public state.