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:


// 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

use dep::aztec::macros::aztec;

#[aztec]
contract TokenBridge {
use dep::aztec::prelude::{AztecAddress, EthAddress, SharedImmutable};

use dep::token_portal_content_hash_lib::{
get_mint_to_private_content_hash, get_mint_to_public_content_hash,
get_withdraw_content_hash,
};

use dep::token::Token;

use dep::aztec::macros::{
functions::{initializer, internal, private, public, view},
storage::storage,
};
}

Inside this block (before the last }), paste this to initialize the constructor:

token_bridge_storage_and_constructor
// Storage structure, containing all storage, and specifying what slots they use.
#[storage]
struct Storage<Context> {
token: SharedImmutable<AztecAddress, Context>,
portal_address: SharedImmutable<EthAddress, Context>,
}

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

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
#[public]
fn claim_public(to: AztecAddress, amount: Field, secret: Field, message_leaf_index: Field) {
let content_hash = get_mint_to_public_content_hash(to, amount);

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

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

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_to_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_to_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_to_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
/// Claims the bridged tokens and makes them accessible in private. Note that recipient's address is not revealed
/// but the amount is. Hence it's most likely possible to determine to which L1 deposit this claim corresponds to
/// (unless there are multiple pending deposits of the same amount).
/// TODO(#8416): Consider creating a truly private claim flow.
#[private]
fn claim_private(
recipient: AztecAddress, // recipient of the bridged tokens
amount: Field,
secret_for_L1_to_L2_message_consumption: Field, // secret used to consume the L1 to L2 message
message_leaf_index: Field,
) {
// Consume L1 to L2 message and emit nullifier
let content_hash = get_mint_to_private_content_hash(amount);
context.consume_l1_to_l2_message(
content_hash,
secret_for_L1_to_L2_message_consumption,
storage.portal_address.read(),
message_leaf_index,
);

// Read the token address from storage
let token_address = storage.token.read();

// At last we mint the tokens
Token::at(token_address).mint_to_private(context.msg_sender(), recipient, amount).call(
&mut context,
);
}
Source code: noir-projects/noir-contracts/contracts/token_bridge_contract/src/main.nr#L94-L125

The get_mint_to_private_content_hash function is imported from the token_portal_content_hash_lib.

If the content hashes were constructed similarly for mint_to_private and mint_to_public, 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.

Note that the TokenBridge contract should be an authorized minter in the corresponding Token contract so that it is able to complete the private mint to the intended recipient.

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