L2 Contracts (Aztec)
This page goes over the code in the L2 contract for Uniswap, which works alongside a token bridge (codealong tutorial).
Main.nr
Setup and constructor
mod util;
// Demonstrates how to use portal contracts to swap on L1 Uniswap with funds on L2
// Has two separate flows for private and public respectively
// Uses the token bridge contract, which tells which input token we need to talk to and handles the exit funds to L1
use dep::aztec::macros::aztec;
#[aztec]
contract Uniswap {
use dep::aztec::prelude::{AztecAddress, EthAddress, FunctionSelector, SharedImmutable};
use dep::authwit::auth::{
assert_current_call_valid_authwit_public, compute_authwit_message_hash_from_call,
set_authorized,
};
use crate::util::{compute_swap_private_content_hash, compute_swap_public_content_hash};
use dep::aztec::macros::{
functions::{initializer, internal, private, public, view},
storage::storage,
};
use dep::token::Token;
use dep::token_bridge::TokenBridge;
#[storage]
struct Storage<Context> {
portal_address: SharedImmutable<EthAddress, Context>,
}
#[public]
#[initializer]
fn constructor(portal_address: EthAddress) {
storage.portal_address.initialize(portal_address);
}
Source code: noir-projects/noir-contracts/contracts/uniswap_contract/src/main.nr#L1-L36
We just need to store the portal address for the token that we want to swap.
Public swap
#[public]
fn swap_public(
sender: AztecAddress,
input_asset_bridge: AztecAddress,
input_amount: Field,
output_asset_bridge: AztecAddress,
// params for using the transfer approval
nonce_for_transfer_approval: Field,
// params for the swap
uniswap_fee_tier: Field,
minimum_output_amount: Field,
// params for the depositing output_asset back to Aztec
recipient: AztecAddress,
secret_hash_for_L1_to_l2_message: Field,
caller_on_L1: EthAddress,
// nonce for someone to call swap on sender's behalf
nonce_for_swap_approval: Field,
) {
if (!sender.eq(context.msg_sender())) {
assert_current_call_valid_authwit_public(&mut context, sender);
}
let input_asset = TokenBridge::at(input_asset_bridge).get_token().view(&mut context);
// Transfer funds to this contract
Token::at(input_asset)
.transfer_in_public(
sender,
context.this_address(),
input_amount,
nonce_for_transfer_approval,
)
.call(&mut context);
// Approve bridge to burn this contract's funds and exit to L1 Uniswap Portal
Uniswap::at(context.this_address())
._approve_bridge_and_exit_input_asset_to_L1(
input_asset,
input_asset_bridge,
input_amount,
)
.call(&mut context);
// Create swap message and send to Outbox for Uniswap Portal
// this ensures the integrity of what the user originally intends to do on L1.
let input_asset_bridge_portal_address =
TokenBridge::at(input_asset_bridge).get_portal_address_public().view(&mut context);
let output_asset_bridge_portal_address =
TokenBridge::at(output_asset_bridge).get_portal_address_public().view(&mut context);
// ensure portal exists - else funds might be lost
assert(
!input_asset_bridge_portal_address.is_zero(),
"L1 portal address of input_asset's bridge is 0",
);
assert(
!output_asset_bridge_portal_address.is_zero(),
"L1 portal address of output_asset's bridge is 0",
);
let content_hash = compute_swap_public_content_hash(
input_asset_bridge_portal_address,
input_amount,
uniswap_fee_tier,
output_asset_bridge_portal_address,
minimum_output_amount,
recipient,
secret_hash_for_L1_to_l2_message,
caller_on_L1,
);
context.message_portal(storage.portal_address.read(), content_hash);
}
Source code: noir-projects/noir-contracts/contracts/uniswap_contract/src/main.nr#L38-L109
- We check that
msg.sender()
has appropriate approval to call this on behalf of the sender by constructing an authwit message and checking iffrom
has given the approval (read more about authwit here). - We fetch the underlying aztec token that needs to be swapped.
- We transfer the user’s funds to the Uniswap contract. Like with Ethereum, the user must have provided approval to the Uniswap contract to do so. The user must provide the nonce they used in the approval for transfer, so that Uniswap can send it to the token contract, to prove it has appropriate approval.
- Funds are added to the Uniswap contract.
- Uniswap must exit the input tokens to L1. For this it has to approve the bridge to burn its tokens on its behalf and then actually exit the funds. We call the
exit_to_l1_public()
method on the token bridge. We use the public flow for exiting since we are operating on public state. - It is not enough for us to simply emit a message to withdraw the funds. We also need to emit a message to display our swap intention. If we do not do this, there is nothing stopping a third party from calling the Uniswap portal with their own parameters and consuming our message.
So the Uniswap portal (on L1) needs to know:
- The token portals for the input and output token (to withdraw the input token to L1 and later deposit the output token to L2)
- The amount of input tokens they want to swap
- The Uniswap fee tier they want to use
- The minimum output amount they can accept (for slippage protection)
The Uniswap portal must first withdraw the input tokens, then check that the swap message exists in the outbox, execute the swap, and then call the output token to deposit the swapped tokens to L2. So the Uniswap portal must also be pass any parameters needed to complete the deposit of swapped tokens to L2. From the tutorial on building token bridges we know these are:
- The address on L2 which must receive the output tokens (remember this is public flow)
- The secret hash for consume the L1 to L2 message. Since this is the public flow the preimage doesn’t need to be a secret.
You can find the corresponding function on the L1 contracts page.
Private swap
#[private]
fn swap_private(
input_asset: AztecAddress, // since private, we pass here and later assert that this is as expected by input_bridge
input_asset_bridge: AztecAddress,
input_amount: Field,
output_asset_bridge: AztecAddress,
// params for using the transfer_to_public approval
nonce_for_transfer_to_public_approval: Field,
// params for the swap
uniswap_fee_tier: Field, // which uniswap tier to use (eg 3000 for 0.3% fee)
minimum_output_amount: Field, // minimum output amount to receive (slippage protection for the swap)
// params for the depositing output_asset back to Aztec
secret_hash_for_L1_to_l2_message: Field, // for when l1 uniswap portal inserts the message to consume output assets on L2
caller_on_L1: EthAddress, // ethereum address that can call this function on the L1 portal (0x0 if anyone can call)
) {
// Assert that user provided token address is same as expected by token bridge.
// we can't directly use `input_asset_bridge.token` because that is a public method and public can't return data to private
Uniswap::at(context.this_address())
._assert_token_is_same(input_asset, input_asset_bridge)
.enqueue_view(&mut context);
// Transfer funds to this contract
Token::at(input_asset)
.transfer_to_public(
context.msg_sender(),
context.this_address(),
input_amount,
nonce_for_transfer_to_public_approval,
)
.call(&mut context);
// Approve bridge to burn this contract's funds and exit to L1 Uniswap Portal
Uniswap::at(context.this_address())
._approve_bridge_and_exit_input_asset_to_L1(
input_asset,
input_asset_bridge,
input_amount,
)
.enqueue(&mut context);
// Create swap message and send to Outbox for Uniswap Portal
// this ensures the integrity of what the user originally intends to do on L1.
let input_asset_bridge_portal_address =
TokenBridge::at(input_asset_bridge).get_portal_address().view(&mut context);
let output_asset_bridge_portal_address =
TokenBridge::at(output_asset_bridge).get_portal_address().view(&mut context);
// ensure portal exists - else funds might be lost
assert(
!input_asset_bridge_portal_address.is_zero(),
"L1 portal address of input_asset's bridge is 0",
);
assert(
!output_asset_bridge_portal_address.is_zero(),
"L1 portal address of output_asset's bridge is 0",
);
let content_hash = compute_swap_private_content_hash(
input_asset_bridge_portal_address,
input_amount,
uniswap_fee_tier,
output_asset_bridge_portal_address,
minimum_output_amount,
secret_hash_for_L1_to_l2_message,
caller_on_L1,
);
context.message_portal(storage.portal_address.read(), content_hash);
}
Source code: noir-projects/noir-contracts/contracts/uniswap_contract/src/main.nr#L111-L179
This uses a util function compute_swap_private_content_hash()
- find that here
This flow works similarly to the public flow with a few notable changes:
- Notice how in the
swap_private()
, user has to pass intoken
address which they didn't in the public flow? Sinceswap_private()
is a private method, it can't read what token is publicly stored on the token bridge, so instead the user passes a token address, and_assert_token_is_same()
checks that this user provided address is same as the one in storage. Note that because public functions are executed by the sequencer while private methods are executed locally, all public calls are always done after all private calls are done. So first the burn would happen and only later the sequencer asserts that the token is same. Note that the sequencer just sees a request toexecute_assert_token_is_same
and therefore has no context on what the appropriate private method was. If the assertion fails, then the kernel circuit will fail to create a proof and hence the transaction will be dropped. - In the public flow, the user calls
transfer_in_public()
. Here instead, the user callstransfer_to_public()
. Why? The user can't directly transfer their private tokens (their notes) to the uniswap contract, because later the Uniswap contract has to approve the bridge to burn these notes and withdraw to L1. The authwit flow for the private domain requires a signature from thesender
, which in this case would be the Uniswap contract. For the contract to sign, it would need a private key associated to it. But who would operate this key? - To work around this, the user can transfer to public their private tokens into Uniswap L2 contract. Transferring to public would convert user's private notes to public balance. It is a private method on the token contract that reduces a user’s private balance and then calls a public method to increase the recipient’s (ie Uniswap) public balance. Remember that first all private methods are executed and then later all public methods will be - so the Uniswap contract won’t have the funds until public execution begins.
- Now uniswap has public balance (like with the public flow). Hence,
swap_private()
calls the internal public method which approves the input token bridge to burn Uniswap’s tokens and callsexit_to_l1_public
to create an L2 → L1 message to exit to L1. - Constructing the message content for swapping works exactly as the public flow except instead of specifying who would be the Aztec address that receives the swapped funds, we specify a secret hash. Only those who know the preimage to the secret can later redeem the minted notes to themselves.
Approve the bridge to burn this contract's funds
Both public and private swap functions call this function:
// This helper method approves the bridge to burn this contract's funds and exits the input asset to L1
// Assumes contract already has funds.
// Assume `token` relates to `token_bridge` (ie token_bridge.token == token)
// Note that private can't read public return values so created an internal public that handles everything
// this method is used for both private and public swaps.
#[public]
#[internal]
fn _approve_bridge_and_exit_input_asset_to_L1(
token: AztecAddress,
token_bridge: AztecAddress,
amount: Field,
) {
// Since we will authorize and instantly spend the funds, all in public, we can use the same nonce
// every interaction. In practice, the authwit should be squashed, so this is also cheap!
let nonce = 0xdeadbeef;
let selector = FunctionSelector::from_signature("burn_public((Field),Field,Field)");
let message_hash = compute_authwit_message_hash_from_call(
token_bridge,
token,
context.chain_id(),
context.version(),
selector,
[context.this_address().to_field(), amount, nonce],
);
// We need to make a call to update it.
set_authorized(&mut context, message_hash, true);
let this_portal_address = storage.portal_address.read();
// Exit to L1 Uniswap Portal !
TokenBridge::at(token_bridge)
.exit_to_l1_public(this_portal_address, amount, this_portal_address, nonce)
.call(&mut context)
}
Source code: noir-projects/noir-contracts/contracts/uniswap_contract/src/main.nr#L181-L217
Assertions
#[public]
#[internal]
#[view]
fn _assert_token_is_same(token: AztecAddress, token_bridge: AztecAddress) {
assert(
token.eq(TokenBridge::at(token_bridge).get_token().view(&mut context)),
"input_asset address is not the same as seen in the bridge contract",
);
}
Source code: noir-projects/noir-contracts/contracts/uniswap_contract/src/main.nr#L219-L229
This is a simple function that asserts that the token passed in to the function is the one that the bridge is associated with.
Utils
Compute content hash for public
use dep::aztec::prelude::{AztecAddress, EthAddress};
use dep::aztec::protocol_types::hash::sha256_to_field;
// This method computes the L2 to L1 message content hash for the public
// refer `l1-contracts/test/portals/UniswapPortal.sol` on how L2 to L1 message is expected
pub fn compute_swap_public_content_hash(
input_asset_bridge_portal_address: EthAddress,
input_amount: Field,
uniswap_fee_tier: Field,
output_asset_bridge_portal_address: EthAddress,
minimum_output_amount: Field,
aztec_recipient: AztecAddress,
secret_hash_for_L1_to_l2_message: Field,
caller_on_L1: EthAddress,
) -> Field {
let mut hash_bytes = [0; 260]; // 8 fields of 32 bytes each + 4 bytes fn selector
let input_token_portal_bytes: [u8; 32] =
input_asset_bridge_portal_address.to_field().to_be_bytes();
let in_amount_bytes: [u8; 32] = input_amount.to_be_bytes();
let uniswap_fee_tier_bytes: [u8; 32] = uniswap_fee_tier.to_be_bytes();
let output_token_portal_bytes: [u8; 32] =
output_asset_bridge_portal_address.to_field().to_be_bytes();
let amount_out_min_bytes: [u8; 32] = minimum_output_amount.to_be_bytes();
let aztec_recipient_bytes: [u8; 32] = aztec_recipient.to_field().to_be_bytes();
let secret_hash_for_L1_to_l2_message_bytes: [u8; 32] =
secret_hash_for_L1_to_l2_message.to_be_bytes();
let caller_on_L1_bytes: [u8; 32] = caller_on_L1.to_field().to_be_bytes();
// The purpose of including the following selector is to make the message unique to that specific call. Note that
// it has nothing to do with calling the function.
let selector = comptime {
std::hash::keccak256(
"swap_public(address,uint256,uint24,address,uint256,bytes32,bytes32,address)".as_bytes(),
75,
)
};
hash_bytes[0] = selector[0];
hash_bytes[1] = selector[1];
hash_bytes[2] = selector[2];
hash_bytes[3] = selector[3];
for i in 0..32 {
hash_bytes[i + 4] = input_token_portal_bytes[i];
hash_bytes[i + 36] = in_amount_bytes[i];
hash_bytes[i + 68] = uniswap_fee_tier_bytes[i];
hash_bytes[i + 100] = output_token_portal_bytes[i];
hash_bytes[i + 132] = amount_out_min_bytes[i];
hash_bytes[i + 164] = aztec_recipient_bytes[i];
hash_bytes[i + 196] = secret_hash_for_L1_to_l2_message_bytes[i];
hash_bytes[i + 228] = caller_on_L1_bytes[i];
}
let content_hash = sha256_to_field(hash_bytes);
content_hash
}
Source code: noir-projects/noir-contracts/contracts/uniswap_contract/src/util.nr#L1-L58
This method computes the L2 to L1 message content hash for the public. To find out how it is consumed on L1, view the L1 contracts page
Compute content hash for private
// This method computes the L2 to L1 message content hash for the private
// refer `l1-contracts/test/portals/UniswapPortal.sol` on how L2 to L1 message is expected
pub fn compute_swap_private_content_hash(
input_asset_bridge_portal_address: EthAddress,
input_amount: Field,
uniswap_fee_tier: Field,
output_asset_bridge_portal_address: EthAddress,
minimum_output_amount: Field,
secret_hash_for_L1_to_l2_message: Field,
caller_on_L1: EthAddress,
) -> Field {
let mut hash_bytes = [0; 228]; // 7 fields of 32 bytes each + 4 bytes fn selector
let input_token_portal_bytes: [u8; 32] =
input_asset_bridge_portal_address.to_field().to_be_bytes();
let in_amount_bytes: [u8; 32] = input_amount.to_be_bytes();
let uniswap_fee_tier_bytes: [u8; 32] = uniswap_fee_tier.to_be_bytes();
let output_token_portal_bytes: [u8; 32] =
output_asset_bridge_portal_address.to_field().to_be_bytes();
let amount_out_min_bytes: [u8; 32] = minimum_output_amount.to_be_bytes();
let secret_hash_for_L1_to_l2_message_bytes: [u8; 32] =
secret_hash_for_L1_to_l2_message.to_be_bytes();
let caller_on_L1_bytes: [u8; 32] = caller_on_L1.to_field().to_be_bytes();
// The purpose of including the following selector is to make the message unique to that specific call. Note that
// it has nothing to do with calling the function.
let selector = comptime {
std::hash::keccak256(
"swap_private(address,uint256,uint24,address,uint256,bytes32,address)".as_bytes(),
68,
)
};
hash_bytes[0] = selector[0];
hash_bytes[1] = selector[1];
hash_bytes[2] = selector[2];
hash_bytes[3] = selector[3];
for i in 0..32 {
hash_bytes[i + 4] = input_token_portal_bytes[i];
hash_bytes[i + 36] = in_amount_bytes[i];
hash_bytes[i + 68] = uniswap_fee_tier_bytes[i];
hash_bytes[i + 100] = output_token_portal_bytes[i];
hash_bytes[i + 132] = amount_out_min_bytes[i];
hash_bytes[i + 164] = secret_hash_for_L1_to_l2_message_bytes[i];
hash_bytes[i + 196] = caller_on_L1_bytes[i];
}
let content_hash = sha256_to_field(hash_bytes);
content_hash
}
Source code: noir-projects/noir-contracts/contracts/uniswap_contract/src/util.nr#L60-L110
This method computes the L2 to L1 message content hash for the private. To find out how it is consumed on L1, view the L1 contracts page.
Redeeming assets
So you emitted a message to withdraw input tokens to L1 and a message to swap. Then you or someone on your behalf can swap on L1 and emit a message to deposit swapped assets to L2.