Depositing Tokens to Aztec
In this step, we will write our token portal contract on L1.
Initialize Solidity contract
In l1-contracts/contracts
in your file called TokenPortal.sol
paste this:
pragma solidity >=0.8.18;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
// Messaging
import {IRegistry} from "@aztec/l1-contracts/src/core/interfaces/messagebridge/IRegistry.sol";
import {IInbox} from "@aztec/l1-contracts/src/core/interfaces/messagebridge/IInbox.sol";
import {IOutbox} from "@aztec/l1-contracts/src/core/interfaces/messagebridge/IOutbox.sol";
import {DataStructures} from "@aztec/l1-contracts/src/core/libraries/DataStructures.sol";
import {Hash} from "@aztec/l1-contracts/src/core/libraries/Hash.sol";
contract TokenPortal {
using SafeERC20 for IERC20;
event DepositToAztecPublic(
bytes32 to, uint256 amount, bytes32 secretHash, bytes32 key, uint256 index
);
event DepositToAztecPrivate(
uint256 amount, bytes32 secretHashForL2MessageConsumption, bytes32 key, uint256 index
);
IRegistry public registry;
IERC20 public underlying;
bytes32 public l2Bridge;
function initialize(address _registry, address _underlying, bytes32 _l2Bridge) external {
registry = IRegistry(_registry);
underlying = IERC20(_underlying);
l2Bridge = _l2Bridge;
}
This imports relevant files including the interfaces used by the Aztec rollup. And initializes the contract with the following parameters:
- rollup registry address (that stores the current rollup, inbox and outbox contract addresses)
- The ERC20 token the portal corresponds to
- The address of the sister contract on Aztec to where the token will send messages to (for depositing tokens or from where to withdraw the tokens)
Create a basic ERC20 contract that can mint tokens to anyone. We will use this to test.
Create a file TestERC20.sol
in the same folder and add:
pragma solidity >=0.8.27;
import {Ownable} from "@oz/access/Ownable.sol";
import {ERC20} from "@oz/token/ERC20/ERC20.sol";
import {IMintableERC20} from "./../governance/interfaces/IMintableERC20.sol";
contract TestERC20 is ERC20, IMintableERC20, Ownable {
bool public freeForAll = false;
modifier ownerOrFreeForAll() {
if (msg.sender != owner() && !freeForAll) {
revert("Not owner or free for all");
}
_;
}
constructor(string memory _name, string memory _symbol, address _owner)
ERC20(_name, _symbol)
Ownable(_owner)
{}
// solhint-disable-next-line comprehensive-interface
function setFreeForAll(bool _freeForAll) external onlyOwner {
freeForAll = _freeForAll;
}
function mint(address _to, uint256 _amount) external override(IMintableERC20) ownerOrFreeForAll {
_mint(_to, _amount);
}
}
Source code: l1-contracts/src/mock/TestERC20.sol#L2-L33
Replace the openzeppelin import with this:
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
Depositing tokens to Aztec publicly
Next, we will write a function that is used to deposit funds on L1 that a user may have into an Aztec portal and send a message to the Aztec rollup to mint tokens publicly on Aztec.
Paste this in TokenPortal.sol
/**
* @notice Deposit funds into the portal and adds an L2 message which can only be consumed publicly on Aztec
* @param _to - The aztec address of the recipient
* @param _amount - The amount to deposit
* @param _secretHash - The hash of the secret consumable message. The hash should be 254 bits (so it can fit in a Field element)
* @return The key of the entry in the Inbox and its leaf index
*/
function depositToAztecPublic(bytes32 _to, uint256 _amount, bytes32 _secretHash)
external
returns (bytes32, uint256)
{
// Preamble
IInbox inbox = IRollup(registry.getRollup()).INBOX();
DataStructures.L2Actor memory actor = DataStructures.L2Actor(l2Bridge, 1);
// Hash the message content to be reconstructed in the receiving contract
// The purpose of including the function selector is to make the message unique to that specific call. Note that
// it has nothing to do with calling the function.
bytes32 contentHash =
Hash.sha256ToField(abi.encodeWithSignature("mint_to_public(bytes32,uint256)", _to, _amount));
// Hold the tokens in the portal
underlying.safeTransferFrom(msg.sender, address(this), _amount);
// Send message to rollup
(bytes32 key, uint256 index) = inbox.sendL2Message(actor, contentHash, _secretHash);
// Emit event
emit DepositToAztecPublic(_to, _amount, _secretHash, key, index);
return (key, index);
}
Source code: l1-contracts/test/portals/TokenPortal.sol#L39-L72
Here is an explanation of what it is doing:
- We first ask the registry for the inbox contract address (to which we send messages to)
- We construct the “content” of the message we need to send to the recipient on Aztec.
- The content is limited to a single field (~254 bits). So if the content is larger, we have to hash it and the hash can be passed along.
- We use our utility method that creates a sha256 hash but truncates it to fit into a field
- Since we want to mint tokens on Aztec publicly, the content here is the amount to mint and the address on Aztec who will receive the tokens.
- We encode this message as a mint_to_public function call, to specify the exact intentions and parameters we want to execute on L2.
- In reality the content can be constructed in any manner as long as the sister contract on L2 can also create it. But for clarity, we are constructing the content like an ABI encoded function call.
- It is good practice to include all parameters used by L2 into this content (like the amount and to) so that a malicious actor can’t change the to to themselves when consuming the message.
- The content is limited to a single field (~254 bits). So if the content is larger, we have to hash it and the hash can be passed along.
- The tokens are transferred from the user to the portal using
underlying.safeTransferFrom()
. This puts the funds under the portal's control. - Next we send the message to the inbox contract. The inbox expects the following parameters:
- recipient (called
actor
here), a struct:- the sister contract address on L2 that can consume the message.
- The version - akin to THE chainID of Ethereum. By including a version, an ID, we can prevent replay attacks of the message (without this the same message might be replayable on other aztec networks that might exist).
- A secret hash (fit to a field element). This is mainly used in the private domain and the preimage of the hash doesn’t need to be secret for the public flow. When consuming the message, one must provide the preimage. More on this when we create the private flow for depositing tokens.
- recipient (called
- It returns a
bytes32 key
which is the id for this message in the Inbox.
So in summary, it deposits tokens to the portal, encodes a mint message, hashes it, and sends it to the Aztec rollup via the Inbox. The L2 token contract can then mint the tokens when it processes the message.
Depositing tokens to Aztec privately
Let’s do the similar for the private flow:
/**
* @notice Deposit funds into the portal and adds an L2 message which can only be consumed privately on Aztec
* @param _amount - The amount to deposit
* @param _secretHashForL2MessageConsumption - The hash of the secret consumable L1 to L2 message. The hash should be 254 bits (so it can fit in a Field element)
* @return The key of the entry in the Inbox and its leaf index
*/
function depositToAztecPrivate(uint256 _amount, bytes32 _secretHashForL2MessageConsumption)
external
returns (bytes32, uint256)
{
// Preamble
IInbox inbox = IRollup(registry.getRollup()).INBOX();
DataStructures.L2Actor memory actor = DataStructures.L2Actor(l2Bridge, 1);
// Hash the message content to be reconstructed in the receiving contract - the signature below does not correspond
// to a real function. It's just an identifier of an action.
bytes32 contentHash =
Hash.sha256ToField(abi.encodeWithSignature("mint_to_private(uint256)", _amount));
// Hold the tokens in the portal
underlying.safeTransferFrom(msg.sender, address(this), _amount);
// Send message to rollup
(bytes32 key, uint256 index) =
inbox.sendL2Message(actor, contentHash, _secretHashForL2MessageConsumption);
// Emit event
emit DepositToAztecPrivate(_amount, _secretHashForL2MessageConsumption, key, index);
return (key, index);
}
Source code: l1-contracts/test/portals/TokenPortal.sol#L74-L106
Here we want to send a message to mint tokens privately on Aztec! Some key differences from the previous method are:
- The content hash uses a different function name -
mint_to_private
. This is done to make it easy to separate concerns. If the contentHash between the public and private message was the same, then an attacker could consume a private message publicly! - Like with the public flow, we move the user’s funds to the portal
- We now send the message to the inbox with the
recipient
(the sister contract on L2 along with the version of aztec the message is intended for) and thesecretHashForL2MessageConsumption
(such that on L2, the consumption of the message can be private).
Note that because L1 is public, everyone can inspect and figure out the contentHash and the recipient contract address.
So how do we privately consume the message on Aztec?
On Aztec, anytime something is consumed (i.e. deleted), we emit a nullifier hash and add it to the nullifier tree. This prevents double-spends. The nullifier hash is a hash of the message that is consumed. So without the secret, one could reverse engineer the expected nullifier hash that might be emitted on L2 upon message consumption. To consume the message on L2, the user provides a secret to the private function, which computes the hash and asserts that it matches to what was provided in the L1->L2 message. This secret is included in the nullifier hash computation and the nullifier is added to the nullifier tree. Anyone inspecting the blockchain won’t know which nullifier hash corresponds to the L1->L2 message consumption.
Secret hashes are Pedersen hashes since the hash has to be computed on L2 and sha256 hash is very expensive for zk circuits. The content hash however is a sha256 hash truncated to a field as shown before.
In the next step we will start writing our L2 smart contract to mint these tokens on L2.