AzRC-20: VanillaToken

ARC-420 (aka Vanilla Token)

Aztec Request for Comment - VanillaToken

Abstract

A standard interface for tokens in the Aztec environment, enabling wallet providers and applications to interact with token contracts regardless of their bytecode implementation details.

ARC-420 was chosen to differentiate itself from both ERC20 and ARC20 (Bitcoin Atomicals standard), and present as a first iteration of a similar proposition standard.

Motivation

The standardization of token interfaces in the Aztec ecosystem serves two critical purposes: enabling wallet integration and facilitating DeFi protocol composability.

Wallet Integration:

  • Generic public functionality can be implemented without token-specific knowledge through standard interface methods like balance_of_public, metadata getters, and total_supply
  • Private operations require wallets to register the token’s specific bytecode (made available through block explorers or similar tools) to execute methods like balance_of_private and private transfers

DeFi Composability:

The specification enables the complete lifecycle of DeFi interactions:

  1. Private-to-Private: Core confidential transfer functionality
  2. Private-to-Public: Opening positions in DeFi protocols
  3. Public-to-Private: Transferring tokens into private state
  4. Public-to-Public: Standard public transfers

All Aztec interactions begin in private state, where users may initiate flows by moving tokens public to interact with DeFi protocols, that can later shield yields or received funds back to the private state. This enables natural interaction patterns like privately providing liquidity to public AMMs, executing swaps, and privately receiving trading fees or farming rewards.

Definitions

  • The transfer method included in Aztec packages’ repository was deprecated because of being non-explicit, the added gates for including the from: this and nonce: 0 for replicating the method with transfer_private_to_private adds roughly 1k gates, which can be negligible on the whole transfer mechanism proof size (close to 300k, when counting account contract + token proofs).
  • Similar to EVM ERC20 tokens, all “transfer flow” methods should revert if the method isn’t being called with a valid authentication mechanism, these are (1) being the msg_sender of the call, (2) presenting a valid authentication witness, or (3) other mechanisms. Instead of implementing an “approve and transfer” mechanism relying on storage, the proposed preferred mechanism is signed Authentication Witnesses, which benefit from the nonce argument to increase the security of the mechanism.
  • The user should be abstracted from knowing the underlying private Notes that represent their balance, instead, methods such as balance_of_private should aggregate them in a numeric balance, and transfer flows should make sure a user can spend multiple Notes when trying to spend an amount of Tokens.

Specification

Optional methods

  • Metadata Public Getters

    fn get_name() -> FieldCompressedString
    
    fn get_symbol() -> FieldCompressedString
    
    fn get_decimals() -> u8
    

Required methods

  • Private Getters [Brillig code]

    /// @notice Returns the total balance of Notes owned by the provided address
    //          within the calling PXE's database
    /// @dev    Raw signature: balance_of_private((Field))
    /// @param  from The address to check the balance for
    /// @return The aggregated balance from all Notes where owner=from
    fn balance_of_private(
        from: AztecAddress
    ) -> U128
    
  • Public Getters

    /// @notice Returns the total amount of tokens in circulation
    //          (sum of public and private balances)
    /// @return The total supply of tokens
    fn total_supply() -> U128
    
    /// @notice Returns the public balance of the provided address
    /// @dev    Raw signature: balance_of_public((Field))
    /// @param  from The address to check the balance for
    /// @return The public balance for the specified address
    fn balance_of_public(
        from: AztecAddress
    ) -> U128
    
  • Private Methods

    /// @notice Transfers tokens privately from one address to another
    /// @dev    Raw signature: transfer_private_to_private((Field),(Field),(Field,Field),Field)
    /// @param  from The sender's address
    /// @param  to The recipient's address
    /// @param  amount The amount of tokens to transfer
    /// @param  nonce A unique nonce for this transfer
    fn transfer_private_to_private(
        from: AztecAddress,
        to: AztecAddress,
        amount: U128,
        nonce: Field
    )
    
    /// @notice Transfers tokens from private balance to public balance
    /// @dev    Raw signature: transfer_private_to_public((Field),(Field),(Field,Field),Field)
    /// @param  from The sender's address
    /// @param  to The recipient's address
    /// @param  amount The amount of tokens to transfer
    /// @param  nonce A unique nonce for this transfer
    fn transfer_private_to_public(
        from: AztecAddress,
        to: AztecAddress,
        amount: U128,
        nonce: Field
    )
    
    /// @notice Transfers tokens from public balance to private balance
    /// @dev    Raw signature: transfer_public_to_private((Field),(Field),(Field,Field),Field)
    /// @dev    This is a private entrypoint since the execution flow is private->public
    /// @param  from The sender's address
    /// @param  to The recipient's address
    /// @param  amount The amount of tokens to transfer
    /// @param  nonce A unique nonce for this transfer
    fn transfer_public_to_private(
        from: AztecAddress,
        to: AztecAddress,
        amount: U128,
        nonce: Field
    )
    
    
    /// @notice Prepares the first step of a public to private transfer
    //          by sending a hiding point from private to public storage
    /// @dev    Raw signature: prepare_transfer_public_to_private((Field),(Field))
    /// @param  from The sender's address
    /// @param  to The recipient's address
    /// @return The hiding point that will be stored in public storage
    fn prepare_transfer_public_to_private(
        from: AztecAddress,
        to: AztecAddress
    ) -> Field
    
    
  • Public Methods

    /// @notice Transfers tokens publicly from one address to another
    /// @dev     Raw signature: transfer_public_to_public((Field),(Field),(Field,Field),Field)
    /// @param  from The sender's address
    /// @param  to The recipient's address
    /// @param  amount The amount of tokens to transfer
    /// @param  nonce A unique nonce for this transfer
    fn transfer_public_to_public(
        from: AztecAddress,
        to: AztecAddress,
        amount: U128,
        nonce: Field
    )
    
    
    /// @notice Transfers tokens from public to private balance using a previously stored
    ///         hiding point that was created in a private context
    /// @dev    Raw signature: finalize_transfer_public_to_private((Field),Field,(Field,Field),Field)
    /// @param  from The sender's address
    /// @param  to_hiding_point The previously stored hiding point from the prepare step
    /// @param  amount The amount of tokens to transfer
    /// @param  nonce A unique nonce for this transfer
    fn finalize_transfer_public_to_private(
        from: AztecAddress,
        to_hiding_point: Point,
        amount: U128,
        nonce: Field
    )
    
    

Future improvements (opinionated)

In order for higher priority to lower priority of defining. Since at the writing of this document the stage of Aztec ecosystem is pre-beta, we consider optimizations and internal mechanisms of the token to be of lesser priority, focusing our efforts in defining the ones that have the higher impact on dev experience.

  • (a) Namings

    This item stands as the top of the priorities list, as changing it needs to be replicated across all repositories that integrate with the ARC-20 token standards. Currently repositories are reading Aztec packages’ token, which doesn’t fill the specs described in this comment.

    The method names were chosen to keep a high level of explicitness in what are they’re doing. A counterpoint for them, is that they end up being too long to write them (specially in the case of Transfer to public and prepare refund), we could choose to reduce private to priv and public to pub.

    • Transfer flows:
      • transfer_private_to_public vs transfer_priv_to_pub
    • Partial notes:
      • prepare_transfer_public_to_private vs prepare_transfer_public_to_hiding_point vs transfer_hiding_point_to_public
      • finalize_transfer_public_to_private vs transfer_public_to_private_point
  • (b) Public Authentication

    While authwits show a clear benefit on the private context, their public implementation relies on storage writing instead of pop capsules in the executor’s PXE. This shows a disadvantage vs EVM’s “approve and transfer” mechanism: auth-witnesses require a fixed amount of tokens to be validated, vs approvals that may allow (i.e.) an infinite amount to be transferred on behalf of the approver.

  • (c) Unconstrained logs:
    Transferring tokens without constraining the log encryption may come handy to improve the proving time, but this should be restricted to non-contrarian setups, in which user A won’t grief user B. To implement this, each Private Method should also include a _unconstrained_logs variant that allows this behaviour.
    For example, there should exist both transfer_private_to_private and transfer_private_to_private_unconstrained_logs. The name of the variant should be reconsidered in order to warn about the effects of using these methods.

  • (d) Transfer to public and prepare refund:
    The AMM example shows the need to transfer an initially unknown amount of tokens from private to public. This is currently implemented by sequentially calling (private) transfer_private_to_public, (private) prepare_transfer_public_to_private, and then (in public) finalize_transfer_public_to_private (for the extra amount initially transferred).

    While the latter call cannot be avoided, as it needs to be executed from the public context to define the “extra amount” to return, this flow could be reduced in the private context by adding a transfer_private_to_public_and_prepare_transfer_public_to_private (see Namings) method, and equally use finalize_transfer_public_to_private. Although this is considered to be an optimization, as the three-call mechanism already enables this flow.

  • (e) Use of other Numeric Types (e.g. U253)
    If using 18 decimals (EVM convention), we won’t be able to multiply token amounts and/or process accrueing yield / interests correctly. We’re keeping U128 for now as it is enshrined in aztec-nr, but pointing out that we might need to break 1:1 decimals on bridging, or increase the numeric type to higher values to allow 18 decimals arithmetics. The decision on using now U128 is based exclusively on the nativeness (to Aztec Noir language) of the Numeric Type.

    This decision could be postponed until further development on Numeric Types and definition on how these are going to be packed into Aztec data slots.

  • (f) Public Event logs
    Public transfers could benefit from emitting a standarized event log to facilitate indexers job, while it may incur in extended DA costs with such requirement. For now, until further definitions on how public events will impact on DA, and the need of indexing such transfers, we consider this aspect of the token standard dispensable, from the point of view of the developer experience at this particular stage.

  • (g) One vs N-Notes per transfer flow

    This topic is intrinsic to each token implementation: should transfer flows “merge notes and transfer a balance”, or they should “spend a note” on each transfer flow. Should the latter be chosen, which would make the most “efficient” transfer flow (since it wouldn’t require to recursively fetch notes from the PXE), a “merging notes” method should be implemented. The current preference is to merge on each transfer flow, prioritizing the user’s “private balance” versus the user’s Notes, which the user should be abstracted from knowing they exist.

  • (h) Private metadata getters
    Add private getters for metadata (name, symbol, decimals). Considering that these could be in a future automatically generated from the Storage struct, we consider these getters to be of very low priority.

2 Likes

Two main points:

  1. Please, no 420: I already struggle to get people to take blockchain seriously. Considering Aztec’s heightened scrutiny due to its privacy properties – and that many law enforcement and regulatory agencies are extremely concerned about virtual currencies (especially anonymous ones) in drug trafficking – drug jokes are inappropriate.

  2. Against Standardizing on U128: If Aztec were an L1, I wouldn’t care, but as an L2 on a chain where the standard is uint256 and many tokens are expected to be bridged in, this decision sets people up for failure. There have been numerous losses in the crypto space due to misunderstandings of decimal places and rounding errors. Using u128 introduces another potential source of mistakes, misunderstandings, and bugs. As a team building a bridge, we feel that 1:1 precision is critical – enough so that we may end up incompatible with any token specification that does not allow for it.

Additional Thought (perhaps for a separate proposal):
Several teams (including mine) are working through regulatory and compliance issues with private transfers. To enable consistent interfaces and composability, the best method for token authors to integrate compliance mechanisms so far looks to be having the token make a compliance decision during the transfer via a compliance hook. While the specifics of what compliance data may be required is outside the scope of a generic token standard, it would be extremely useful if token contracts exposed a standardized interface to describe their compliance requirements (for example, to inform the wallet that it must provide a capsule with specific information to the token or its compliance contract to pass a compliance check).

1 Like

Ey Tim, thanks for your reply, as for (1) we tried to not use ERC20 in order not to confuse ppl that we would be using the same interface, and not to be confused with ARC20 that’s a Bitcoin standard, we also received some feedback into moving the Request for Comments into AZRC or AkRC naming, then we can keep the 20.

As for (2), u128 is coming out native now from Noir language itself, which is a huge benefit for method encoding and interfaces. As pointed out in the RC, we’re going forward with such numeric type only because of nativeness, and consider re-visiting this decision before production readiness, in order to avoid footguns on development that can be easily fixed later on.

About the additional thought, we tried to find a “one interface to rule them all” for compliance mechanisms but failed, we believe adding a custom way of overriding the Token’s routines may be better for achieving this goal. A Token issuer will then be able to override the “authwit” check routine, in order to also make an arbitrary call (defined by the dev) to an external contract. Defining how this “arbitrary call” should look like is out of scope for standarizing, but defining how the override should look is already in the process of being defined: ideally you wouldn’t need to re-define all other transfer methods.

What about using Field in public interfaces and bignum crate internally?

// each token will use it's own bignum crate
// if bignum releases a breaking version, 
// the public interface of the token will remain unchanged
use bignum;

contract Token {
    fn transfer(to: AztecAddress, amount: Field) {
        let amount = bignum::U253::from(amount);
        // perform operations with amount which is now U253
        storage.balances(msg_sender) -= amount;
    }
}