Bridge Your NFT to Aztec
Why Bridge an NFT?
Imagine you own a CryptoPunk NFT on Ethereum. You want to use it in games, social apps, or DeFi protocols, but gas fees on Ethereum make every interaction expensive. What if you could move your Punk to Aztec (L2), use it privately in dozens of applications, and then bring it back to Ethereum when you're ready to sell?
In this tutorial, you'll build a private NFT bridge. By the end, you'll understand how portals work and how cross-chain messages flow between L1 and L2.
Before starting, make sure you have the Aztec local network running at version 4.2.0-aztecnr-rc.2. Check out the local network guide for setup instructions.
What You'll Build
You'll create two contracts with privacy at the core:
- NFTPunk (L2) - An NFT contract with encrypted ownership using
PrivateSet - NFTBridge (L2) - A bridge that mints NFTs privately when claiming L1 messages
This tutorial focuses on the L2 side to keep things manageable. You'll learn the essential privacy patterns that apply to any asset bridge on Aztec.
Project Setup
Let's start simple. Since this is an Ethereum project, it's easier to just start with Hardhat:
git clone https://github.com/critesjosh/hardhat-aztec-example
You're cloning a repo here to make it easier for Aztec's l1-contracts to be mapped correctly. You should now have a hardhat-aztec-example folder with Hardhat's default starter, with a few changes in package.json.
We want to add a few more dependencies now before we start:
cd hardhat-aztec-example
yarn add @aztec/aztec.js@4.2.0-aztecnr-rc.2 @aztec/accounts@4.2.0-aztecnr-rc.2 @aztec/stdlib@4.2.0-aztecnr-rc.2 @aztec/wallets@4.2.0-aztecnr-rc.2 tsx
Now start the local network in another terminal:
aztec start --local-network
This should start two important services on ports 8080 and 8545, respectively: Aztec and Anvil (an Ethereum development node).
Part 1: Building the NFT Contract
Let's start with a basic NFT contract on Aztec. That's the representation of the NFT locked on the L2 side:
Let's create that crate in the contracts folder so it looks tidy:
aztec new contracts/aztec/nft
cd contracts/aztec/nft
If you're using VS Code, install the Noir Language Support extension for syntax highlighting, error checking, and code completion while writing Noir contracts.
Open Nargo.toml and make sure aztec is a dependency:
[dependencies]
aztec = { git = "https://github.com/AztecProtocol/aztec-nr", tag = "v4.2.0-aztecnr-rc.2", directory = "aztec" }
Create the NFT Note
First, let's create a custom note type for private NFT ownership. In the src/ directory, create a new file called nft.nr:
touch src/nft.nr
In this file, you're going to create a private note that represents NFT ownership. This is a struct with macros that indicate it is a note that can be compared and packed:
use aztec::{macros::notes::note, protocol::traits::Packable};
#[derive(Eq, Packable)]
#[note]
pub struct NFTNote {
pub token_id: Field,
}
Source code: docs/examples/contracts/nft/src/nft.nr#L1-L9
You now have a note that represents the owner of a particular NFT. Next, move on to the contract itself.
Notes are powerful concepts. Learn more about how to use them in the state management guide.
Define Storage
Back in main.nr, you can now build the contract storage. You need:
- admin: Who controls the contract (set once, never changes)
- minter: The bridge address (set once by admin)
- nfts: Track which NFTs exist (public, needed for bridging)
- owners: Private ownership using the NFTNote
One interesting aspect of this storage configuration is the use of DelayedPublicMutable, which allows private functions to read and use public state. You're using it to publicly track which NFTs are already minted while keeping their owners private. Read more about DelayedPublicMutable in the storage guide.
Write the storage struct and a simple initializer to set the admin in the main.nr file:
use aztec::macros::aztec;
pub mod nft;
#[aztec]
pub contract NFTPunk {
use crate::nft::NFTNote;
use aztec::{
macros::{functions::{external, initializer, only_self}, storage::storage},
protocol::address::AztecAddress,
state_vars::{DelayedPublicMutable, Map, Owned, PrivateSet, PublicImmutable},
};
use aztec::messages::message_delivery::MessageDelivery;
use aztec::note::{
note_getter_options::NoteGetterOptions, note_interface::NoteProperties,
note_viewer_options::NoteViewerOptions,
};
use aztec::utils::comparison::Comparator;
#[storage]
struct Storage<Context> {
admin: PublicImmutable<AztecAddress, Context>,
minter: PublicImmutable<AztecAddress, Context>,
nfts: Map<Field, DelayedPublicMutable<bool, 2, Context>, Context>,
owners: Owned<PrivateSet<NFTNote, Context>, Context>,
}
#[external("public")]
#[initializer]
fn constructor(admin: AztecAddress) {
self.storage.admin.initialize(admin);
}
}
Utility Functions
Add an internal function to handle the DelayedPublicMutable value change. Mark the function as public and #[only_self] so only the contract can call it:
#[external("public")]
#[only_self]
fn _mark_nft_exists(token_id: Field, exists: bool) {
self.storage.nfts.at(token_id).schedule_value_change(exists);
}
Source code: docs/examples/contracts/nft/src/main.nr#L42-L48
This function is marked with #[only_self], meaning only the contract itself can call it. It uses schedule_value_change to update the nfts storage, preventing the same NFT from being minted twice or burned when it doesn't exist. You'll call this public function from a private function later using enqueue_self.
Another useful function checks how many notes a caller has. You can use this later to verify the claim and exit from L2:
#[external("utility")]
unconstrained fn notes_of(from: AztecAddress) -> Field {
let notes = self.storage.owners.at(from).view_notes(NoteViewerOptions::new());
notes.len() as Field
}
Source code: docs/examples/contracts/nft/src/main.nr#L67-L73
Add Minting and Burning
Before anything else, you need to set the minter. This will be the bridge contract, so only the bridge contract can mint NFTs. This value doesn't need to change after initialization. Here's how to initialize the PublicImmutable:
#[external("public")]
fn set_minter(minter: AztecAddress) {
assert(self.storage.admin.read().eq(self.msg_sender()), "caller is not admin");
self.storage.minter.initialize(minter);
}
Source code: docs/examples/contracts/nft/src/main.nr#L34-L40
Now for the magic - minting NFTs privately. The bridge will call this to mint to a user, deliver the note using constrained message delivery (best practice when "sending someone a
note") and then enqueue a public call to the _mark_nft_exists function:
#[external("private")]
fn mint(to: AztecAddress, token_id: Field) {
assert(
self.storage.minter.read().eq(self.msg_sender()),
"caller is not the authorized minter",
);
// we create an NFT note and insert it to the PrivateSet - a collection of notes meant to be read in private
let new_nft = NFTNote { token_id };
self.storage.owners.at(to).insert(new_nft).deliver(MessageDelivery.ONCHAIN_CONSTRAINED);
// calling the internal public function above to indicate that the NFT is taken
self.enqueue_self._mark_nft_exists(token_id, true);
}
Source code: docs/examples/contracts/nft/src/main.nr#L50-L65
The bridge will also need to burn NFTs when users withdraw back to L1:
#[external("private")]
fn burn(from: AztecAddress, token_id: Field) {
assert(
self.storage.minter.read().eq(self.msg_sender()),
"caller is not the authorized minter",
);
// from the NFTNote properties, selects token_id and compares it against the token_id to be burned
let options = NoteGetterOptions::new()
.select(NFTNote::properties().token_id, Comparator.EQ, token_id)
.set_limit(1);
let notes = self.storage.owners.at(from).pop_notes(options);
assert(notes.len() == 1, "NFT not found");
self.enqueue_self._mark_nft_exists(token_id, false);
}
Source code: docs/examples/contracts/nft/src/main.nr#L75-L92
Compiling!
Let's verify it compiles:
aztec compile
🎉 You should see "Compiled successfully!" This means our private NFT contract is ready. Now let's build the bridge.
Part 2: Building the Bridge
We have built the L2 NFT contract. This is the L2 representation of an NFT that is locked on the L1 bridge.
The L2 bridge is the contract that talks to the L1 bridge through cross-chain messaging. You can read more about this protocol here.
Let's create a new contract in the same tidy contracts/aztec folder:
cd ..
aztec new nft_bridge
cd nft_bridge
And again, add the aztec-nr dependency to Nargo.toml. We also need to add the NFTPunk contract we just wrote above:
[dependencies]
aztec = { git="https://github.com/AztecProtocol/aztec-nr", tag = "v4.2.0-aztecnr-rc.2", directory = "aztec" }
NFTPunk = { path = "../nft" }
Understanding Bridges
A bridge has two jobs:
- Claim: When someone deposits an NFT on L1, mint it on L2
- Exit: When someone wants to withdraw, burn on L2 and unlock on L1
This means having knowledge about the L2 NFT contract, and the bridge on the L1 side. That's what goes into our bridge's storage.
Bridge Storage
Clean up main.nr which is just a placeholder, and let's write the storage struct and the constructor. We'll use PublicImmutable since these values never change:
use aztec::macros::aztec;
#[aztec]
pub contract NFTBridge {
use aztec::{
macros::{functions::{external, initializer}, storage::storage},
protocol::{address::{AztecAddress, EthAddress}, hash::sha256_to_field},
state_vars::PublicImmutable,
};
use NFTPunk::NFTPunk;
#[storage]
struct Storage<Context> {
nft: PublicImmutable<AztecAddress, Context>,
portal: PublicImmutable<EthAddress, Context>,
}
#[external("public")]
#[initializer]
fn constructor(nft: AztecAddress) {
self.storage.nft.initialize(nft);
}
#[external("public")]
fn set_portal(portal: EthAddress) {
self.storage.portal.initialize(portal);
}
}
You can't initialize the portal value in the constructor because the L1 portal hasn't been deployed yet. You'll need another function to set it up after the L1 portal is deployed.
Adding the Bridge Functions
The Aztec network provides a way to consume messages from L1 to L2 called consume_l1_to_l2_message.
You need to define how to encode messages. Here's a simple approach: when an NFT is being bridged, the L1 portal sends a hash of its token_id through the bridge, signaling which token_id was locked and can be minted on L2. This approach is simple but sufficient for this tutorial.
Build the claim function, which consumes the message and mints the NFT on the L2 side:
#[external("private")]
fn claim(to: AztecAddress, token_id: Field, secret: Field, message_leaf_index: Field) {
// Compute the message hash that was sent from L1
let token_id_bytes: [u8; 32] = (token_id as Field).to_be_bytes();
let content_hash = sha256_to_field(token_id_bytes);
// Consume the L1 -> L2 message
self.context.consume_l1_to_l2_message(
content_hash,
secret,
self.storage.portal.read(),
message_leaf_index,
);
// Mint the NFT on L2
let nft: AztecAddress = self.storage.nft.read();
self.call(NFTPunk::at(nft).mint(to, token_id));
}
Source code: docs/examples/contracts/nft_bridge/src/main.nr#L31-L50
The secret prevents front-running. Certainly you don't want anyone to claim your NFT on the L2 side by just being faster. Adding a secret acts like a "password": you can only claim it if you know it.
Similarly, exiting to L1 means burning the NFT on the L2 side and pushing a message through the protocol. To ensure only the L1 recipient can claim it, hash the token_id together with the recipient:
#[external("private")]
fn exit(token_id: Field, recipient: EthAddress) {
// Create L2->L1 message to unlock NFT on L1
let token_id_bytes: [u8; 32] = token_id.to_be_bytes();
let recipient_bytes: [u8; 20] = recipient.to_be_bytes();
let content = sha256_to_field(token_id_bytes.concat(recipient_bytes));
self.context.message_portal(self.storage.portal.read(), content);
// Burn the NFT on L2
let nft: AztecAddress = self.storage.nft.read();
self.call(NFTPunk::at(nft).burn(self.msg_sender(), token_id));
}
Source code: docs/examples/contracts/nft_bridge/src/main.nr#L52-L65
Cross-chain messaging on Aztec is powerful because it doesn't conform to any specific format—you can structure messages however you want.
Both claim and exit are #[external("private")], which means the bridging process is private—nobody can see who's bridging which NFT by watching the chain.
Compile the Bridge
aztec compile
Bridge compiled successfully! Now process both contracts and generate TypeScript bindings:
cd ../nft
aztec codegen target --outdir ../artifacts
cd ../nft_bridge
aztec codegen target --outdir ../artifacts
An artifacts folder should appear with TypeScript bindings for each contract. You'll use these when deploying the contracts.
Part 3: The Ethereum Side
Now build the L1 contracts. You need:
- A simple ERC721 NFT contract (the "CryptoPunk")
- A portal contract that locks/unlocks NFTs and communicates with Aztec
Install Dependencies
Aztec's contracts are already in your package.json. You just need to add the OpenZeppelin contracts that provide the default ERC721 implementation:
cd ../../..
yarn add @openzeppelin/contracts
Create a Simple NFT
Delete the "Counter" contracts that show up by default in contracts and create contracts/SimpleNFT.sol:
touch contracts/SimpleNFT.sol
Create a minimal NFT contract sufficient for demonstrating bridging:
pragma solidity >=0.8.27;
import {ERC721} from "@oz/token/ERC721/ERC721.sol";
contract SimpleNFT is ERC721 {
uint256 private _currentTokenId;
constructor() ERC721("SimplePunk", "SPUNK") {}
function mint(address to) external returns (uint256) {
uint256 tokenId = _currentTokenId++;
_mint(to, tokenId);
return tokenId;
}
}
Source code: docs/examples/solidity/nft_bridge/SimpleNFT.sol#L2-L18
Create the NFT Portal
The NFT Portal has more code, so build it step-by-step. Create contracts/NFTPortal.sol:
touch contracts/NFTPortal.sol
Initialize it with Aztec's registry, which holds the canonical contracts for Aztec-related contracts, including the Inbox and Outbox. These are the message-passing contracts—Aztec sequencers read any messages on these contracts.
import {IERC721} from "@oz/token/ERC721/IERC721.sol";
import {IRegistry} from "@aztec/governance/interfaces/IRegistry.sol";
import {IInbox} from "@aztec/core/interfaces/messagebridge/IInbox.sol";
import {IOutbox} from "@aztec/core/interfaces/messagebridge/IOutbox.sol";
import {IRollup} from "@aztec/core/interfaces/IRollup.sol";
import {DataStructures} from "@aztec/core/libraries/DataStructures.sol";
import {Hash} from "@aztec/core/libraries/crypto/Hash.sol";
import {Epoch} from "@aztec/core/libraries/TimeLib.sol";
contract NFTPortal {
IRegistry public registry;
IERC721 public nftContract;
bytes32 public l2Bridge;
IRollup public rollup;
IOutbox public outbox;
IInbox public inbox;
uint256 public rollupVersion;
function initialize(address _registry, address _nftContract, bytes32 _l2Bridge) external {
registry = IRegistry(_registry);
nftContract = IERC721(_nftContract);
l2Bridge = _l2Bridge;
rollup = IRollup(address(registry.getCanonicalRollup()));
outbox = rollup.getOutbox();
inbox = rollup.getInbox();
rollupVersion = rollup.getVersion();
}
}
The core logic is similar to the L2 logic. depositToAztec calls the Inbox canonical contract to send a message to Aztec, and withdraw calls the Outbox contract.
Add these two functions with explanatory comments:
// Lock NFT and send message to L2
function depositToAztec(uint256 tokenId, bytes32 secretHash) external returns (bytes32, uint256) {
// Lock the NFT
nftContract.transferFrom(msg.sender, address(this), tokenId);
// Prepare L2 message - just a naive hash of our tokenId
DataStructures.L2Actor memory actor = DataStructures.L2Actor(l2Bridge, rollupVersion);
bytes32 contentHash = Hash.sha256ToField(abi.encode(tokenId));
// Send message to Aztec
(bytes32 key, uint256 index) = inbox.sendL2Message(actor, contentHash, secretHash);
return (key, index);
}
// Unlock NFT after L2 burn
function withdraw(
uint256 tokenId,
Epoch epoch,
uint256 leafIndex,
bytes32[] calldata path
) external {
// Verify message from L2
DataStructures.L2ToL1Msg memory message = DataStructures.L2ToL1Msg({
sender: DataStructures.L2Actor(l2Bridge, rollupVersion),
recipient: DataStructures.L1Actor(address(this), block.chainid),
content: Hash.sha256ToField(abi.encodePacked(tokenId, msg.sender))
});
outbox.consume(message, epoch, leafIndex, path);
// Unlock NFT
nftContract.transferFrom(address(this), msg.sender, tokenId);
}
Source code: docs/examples/solidity/nft_bridge/NFTPortal.sol#L36-L70
The portal handles two flows:
- depositToAztec: Locks NFT on L1, sends message to L2
- withdraw: Verifies L2 message, unlocks NFT on L1