Skip to main content

Minting tokens on Aztec

In this step we will start writing our Aztec.nr bridge smart contract and write a function to consume the message from the token portal to mint funds on Aztec

Initial contract setup

In our token-bridge Aztec project in aztec-contracts, under src there is an example main.nr file. Paste this to define imports and initialize the constructor:

token_bridge_imports

// Minimal implementation of the token bridge that can move funds between L1 <> L2.
// The bridge has a corresponding Portal contract on L1 that it is attached to
// And corresponds to a Token on L2 that uses the `AuthWit` accounts pattern.
// Bridge has to be set as a minter on the token before it can be used

contract TokenBridge {
use dep::aztec::prelude::{FunctionSelector, AztecAddress, EthAddress, PublicMutable, SharedImmutable};

use dep::token_portal_content_hash_lib::{get_mint_public_content_hash, get_mint_private_content_hash, get_withdraw_content_hash};

use dep::token::Token;
Source code: noir-projects/noir-contracts/contracts/token_bridge_contract/src/main.nr#L1-L14
token_bridge_storage_and_constructor
// Storage structure, containing all storage, and specifying what slots they use.
#[aztec(storage)]
struct Storage {
token: PublicMutable<AztecAddress>,
portal_address: SharedImmutable<EthAddress>,
}

// Constructs the contract.
#[aztec(public)]
#[aztec(initializer)]
fn constructor(token: AztecAddress, portal_address: EthAddress) {
storage.token.write(token);
storage.portal_address.initialize(portal_address);
}
Source code: noir-projects/noir-contracts/contracts/token_bridge_contract/src/main.nr#L16-L31

Consume the L1 message

In the previous step, we have moved our funds to the portal and created a L1->L2 message. Upon building the next rollup, the sequencer asks the inbox for any incoming messages and adds them to Aztec’s L1->L2 message tree, so an application on L2 can prove that the message exists and consumes it.

In main.nr, now paste this claim_public function:

claim_public
// Consumes a L1->L2 message and calls the token contract to mint the appropriate amount publicly
#[aztec(public)]
fn claim_public(to: AztecAddress, amount: Field, secret: Field, message_leaf_index: Field) {
let content_hash = get_mint_public_content_hash(to, amount);

// Consume message and emit nullifier
context.consume_l1_to_l2_message(
content_hash,
secret,
storage.portal_address.read_public(),
message_leaf_index
);

// Mint tokens
Token::at(storage.token.read()).mint_public(to, amount).call(&mut context);
}
Source code: noir-projects/noir-contracts/contracts/token_bridge_contract/src/main.nr#L43-L60

The claim_public function enables anyone to consume the message on the user's behalf and mint tokens for them on L2. This is fine as the minting of tokens is done publicly anyway.

What’s happening here?

  1. We first recompute the L1->L2 message content by calling get_mint_public_content_hash(). Note that the method does exactly the same as what the TokenPortal contract does in depositToAztecPublic() to create the content hash.
  2. We then attempt to consume the L1->L2 message. Since we are depositing to Aztec publicly, all of the inputs are public.
    • context.consume_l1_to_l2_message() takes in the few parameters:
      • content_hash: The content - which is reconstructed in the get_mint_public_content_hash()
      • secret: The secret used for consumption, often 0 for public messages
      • sender: Who on L1 sent the message. Which should match the stored portal_address in our case as we only want to allow messages from a specific sender.
      • message_leaf_index: The index in the message tree of the message.
    • Note that the content_hash requires to and amount. If a malicious user tries to mint tokens to their address by changing the to address, the content hash will be different to what the token portal had calculated on L1 and thus not be in the tree, failing the consumption. This is why we add these parameters into the content.
  3. Then we call Token::at(storage.token.read()).mint_public() to mint the tokens to the to address.

Private flow

Now we will create a function to mint the amount privately. Paste this into your main.nr

claim_private
// Consumes a L1->L2 message and calls the token contract to mint the appropriate amount in private assets
// User needs to call token.redeem_shield() to get the private assets
#[aztec(private)]
fn claim_private(
secret_hash_for_redeeming_minted_notes: Field, // secret hash used to redeem minted notes at a later time. This enables anyone to call this function and mint tokens to a user on their behalf
amount: Field,
secret_for_L1_to_L2_message_consumption: Field // secret used to consume the L1 to L2 message
) {
// Consume L1 to L2 message and emit nullifier
let content_hash = get_mint_private_content_hash(secret_hash_for_redeeming_minted_notes, amount);
context.consume_l1_to_l2_message(
content_hash,
secret_for_L1_to_L2_message_consumption,
storage.portal_address.read_private()
);

// Mint tokens on L2
// `mint_private` on token is public. So we call an internal public function
// which then calls the public method on the token contract.
// Since the secret_hash is passed, no secret is leaked.
context.call_public_function(
context.this_address(),
FunctionSelector::from_signature("_call_mint_on_token(Field,Field)"),
[amount, secret_hash_for_redeeming_minted_notes]
);
}
Source code: noir-projects/noir-contracts/contracts/token_bridge_contract/src/main.nr#L80-L107
call_mint_on_token
// This is a public call as we need to read from public storage.
// Also, note that user hashes their secret in private and only sends the hash in public
// meaning only user can `redeem_shield` at a later time with their secret.
#[aztec(public)]
#[aztec(internal)]
fn _call_mint_on_token(amount: Field, secret_hash: Field) {
Token::at(storage.token.read()).mint_private(amount, secret_hash).call(&mut context);
}
Source code: noir-projects/noir-contracts/contracts/token_bridge_contract/src/main.nr#L155-L164

The get_mint_private_content_hash function is imported from the token_portal_content_hash_lib.

If the content hashes were constructed similarly for mint_private and mint_publicly, then content intended for private execution could have been consumed by calling the claim_public method. By making these two content hashes distinct, we prevent this scenario.

While we mint the tokens on L2, we still don’t actually mint them to a certain address. Instead we continue to pass the secret_hash_for_redeeming_minted_notes like we did on L1. This means that a user could reveal their secret for L2 message consumption for anyone to mint tokens on L2 but they can redeem these notes at a later time. This enables a paradigm where an app can manage user’s secrets for L2 message consumption on their behalf. The app or any external party can also mint tokens on the user’s behalf should they be comfortable with leaking the secret for L2 Message consumption. This doesn’t leak any new information to the app because their smart contract on L1 knew that a user wanted to move some amount of tokens to L2. The app still doesn’t know which address on L2 the user wants these notes to be in, but they can mint tokens nevertheless on their behalf.

To mint tokens privately, claim_private calls an internal function _call_mint_on_token() which then calls token.mint_private() which is a public method since it operates on public storage. Note that mint_private (on the token contract) is public because it too reads from public storage. Since the secret_hash_for_redeeming_minted_notes is passed publicly (and not the secret), nothing that should be leaked is, and the only the person that knows the secret can actually redeem their notes at a later time by calling Token.redeem_shield(secret, amount).

In the next step we will see how we can cancel a message.