Skip to content

Commit 5c00bd1

Browse files
authored
chore(docs): refactoring cross-chain tutorial (#17799)
# Bridge Your NFT to Aztec: A Private NFT Bridge Tutorial This PR completely revamps the token bridge tutorial to focus on NFTs with privacy at the core. The new tutorial guides developers through building a complete private NFT bridge between Ethereum and Aztec. ## Key improvements: - Creates a more engaging narrative around bridging CryptoPunks with private ownership - Builds a complete end-to-end solution with 4 contracts (2 on L1, 2 on L2) - Introduces privacy concepts like `PrivateSet` and custom notes for encrypted ownership - Provides detailed explanations of cross-chain messaging between L1 and L2 - Includes complete code examples with step-by-step instructions The tutorial now follows a more logical flow: 1. Building the L2 NFT contract with private ownership 2. Creating the L2 bridge contract for claiming messages 3. Implementing the L1 contracts (NFT and portal) 4. Deploying and testing the full bridging flow This approach gives developers a deeper understanding of Aztec's privacy features while building something practical and useful.
2 parents ca304af + 9fe5abe commit 5c00bd1

File tree

9 files changed

+909
-178
lines changed

9 files changed

+909
-178
lines changed

docs/docs/developers/docs/tutorials/js_tutorials/token_bridge.md

Lines changed: 349 additions & 178 deletions
Large diffs are not rendered by default.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
pragma solidity >=0.8.27;
3+
4+
// docs:start:portal_setup
5+
import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
6+
import {IRegistry} from "@aztec/l1-contracts/src/governance/interfaces/IRegistry.sol";
7+
import {IInbox} from "@aztec/l1-contracts/src/core/interfaces/messagebridge/IInbox.sol";
8+
import {IOutbox} from "@aztec/l1-contracts/src/core/interfaces/messagebridge/IOutbox.sol";
9+
import {IRollup} from "@aztec/l1-contracts/src/core/interfaces/IRollup.sol";
10+
import {DataStructures} from "@aztec/l1-contracts/src/core/libraries/DataStructures.sol";
11+
import {Hash} from "@aztec/l1-contracts/src/core/libraries/crypto/Hash.sol";
12+
13+
contract NFTPortal {
14+
IRegistry public registry;
15+
IERC721 public nftContract;
16+
bytes32 public l2Bridge;
17+
18+
IRollup public rollup;
19+
IOutbox public outbox;
20+
IInbox public inbox;
21+
uint256 public rollupVersion;
22+
23+
function initialize(address _registry, address _nftContract, bytes32 _l2Bridge) external {
24+
registry = IRegistry(_registry);
25+
nftContract = IERC721(_nftContract);
26+
l2Bridge = _l2Bridge;
27+
28+
rollup = IRollup(address(registry.getCanonicalRollup()));
29+
outbox = rollup.getOutbox();
30+
inbox = rollup.getInbox();
31+
rollupVersion = rollup.getVersion();
32+
}
33+
// docs:end:portal_setup
34+
35+
// docs:start:portal_deposit_and_withdraw
36+
// Lock NFT and send message to L2
37+
function depositToAztec(uint256 tokenId, bytes32 secretHash) external returns (bytes32, uint256) {
38+
// Lock the NFT
39+
nftContract.transferFrom(msg.sender, address(this), tokenId);
40+
41+
// Prepare L2 message - just a naive hash of our tokenId
42+
DataStructures.L2Actor memory actor = DataStructures.L2Actor(l2Bridge, rollupVersion);
43+
bytes32 contentHash = Hash.sha256ToField(abi.encode(tokenId));
44+
45+
// Send message to Aztec
46+
(bytes32 key, uint256 index) = inbox.sendL2Message(actor, contentHash, secretHash);
47+
return (key, index);
48+
}
49+
50+
// Unlock NFT after L2 burn
51+
function withdraw(
52+
uint256 tokenId,
53+
uint256 l2BlockNumber,
54+
uint256 leafIndex,
55+
bytes32[] calldata path
56+
) external {
57+
// Verify message from L2
58+
DataStructures.L2ToL1Msg memory message = DataStructures.L2ToL1Msg({
59+
sender: DataStructures.L2Actor(l2Bridge, rollupVersion),
60+
recipient: DataStructures.L1Actor(address(this), block.chainid),
61+
content: Hash.sha256ToField(abi.encodePacked(tokenId, msg.sender))
62+
});
63+
64+
outbox.consume(message, l2BlockNumber, leafIndex, path);
65+
66+
// Unlock NFT
67+
nftContract.transferFrom(address(this), msg.sender, tokenId);
68+
}
69+
}
70+
// docs:end:portal_deposit_and_withdraw
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// docs:start:simple_nft
3+
pragma solidity >=0.8.27;
4+
5+
import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
6+
7+
contract SimpleNFT is ERC721 {
8+
uint256 private _currentTokenId;
9+
10+
constructor() ERC721("SimplePunk", "SPUNK") {}
11+
12+
function mint(address to) external returns (uint256) {
13+
uint256 tokenId = _currentTokenId++;
14+
_mint(to, tokenId);
15+
return tokenId;
16+
}
17+
}
18+
// docs:end:simple_nft
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[package]
2+
name = "nft"
3+
type = "contract"
4+
5+
[dependencies]
6+
aztec = { path = "../../../../noir-projects/aztec-nr/aztec" }
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// docs:start:contract_setup
2+
use aztec::macros::aztec;
3+
pub mod nft;
4+
5+
#[aztec]
6+
pub contract NFTPunk {
7+
use dep::aztec::{
8+
macros::{storage::storage, functions::{utility, private, public, initializer, internal}},
9+
protocol_types::{address::AztecAddress},
10+
state_vars::{PrivateSet, PublicImmutable, delayed_public_mutable::DelayedPublicMutable, Map}
11+
};
12+
use crate::nft::NFTNote;
13+
use dep::aztec::messages::message_delivery::MessageDelivery;
14+
use aztec::note::{note_getter_options::NoteGetterOptions, note_interface::NoteProperties, note_viewer_options::NoteViewerOptions};
15+
use aztec::utils::comparison::Comparator;
16+
17+
#[storage]
18+
struct Storage<Context> {
19+
admin: PublicImmutable<AztecAddress, Context>,
20+
minter: PublicImmutable<AztecAddress, Context>,
21+
nfts: Map<Field, DelayedPublicMutable<bool, 2, Context>, Context>,
22+
owners: Map<AztecAddress, PrivateSet<NFTNote, Context>, Context>,
23+
}
24+
#[public]
25+
#[initializer]
26+
fn constructor(admin: AztecAddress) {
27+
storage.admin.initialize(admin);
28+
}
29+
// docs:end:contract_setup
30+
31+
// docs:start:set_minter
32+
#[public]
33+
fn set_minter(minter: AztecAddress) {
34+
assert(storage.admin.read().eq(context.msg_sender().unwrap()), "caller is not admin");
35+
storage.minter.initialize(minter);
36+
}
37+
// docs:end:set_minter
38+
39+
// docs:start:mark_nft_exists
40+
#[public]
41+
#[internal]
42+
fn _mark_nft_exists(token_id: Field, exists: bool) {
43+
storage.nfts.at(token_id).schedule_value_change(exists);
44+
}
45+
// docs:end:mark_nft_exists
46+
47+
// docs:start:mint
48+
#[private]
49+
fn mint(to: AztecAddress, token_id: Field) {
50+
assert(storage.minter.read().eq(context.msg_sender().unwrap()), "caller is not the authorized minter");
51+
52+
// we create an NFT note and insert it to the PrivateSet - a collection of notes meant to be read in private
53+
let new_nft = NFTNote::new(to, token_id);
54+
storage.owners.at(to).insert(new_nft).emit(&mut context, to, MessageDelivery.CONSTRAINED_ONCHAIN);
55+
56+
// calling the internal public function above to indicate that the NFT is taken
57+
NFTPunk::at(context.this_address())._mark_nft_exists(token_id, true).enqueue(&mut context);
58+
}
59+
// docs:end:mint
60+
61+
// docs:start:notes_of
62+
#[utility]
63+
unconstrained fn notes_of(from: AztecAddress) -> Field {
64+
let notes = storage.owners.at(from).view_notes(NoteViewerOptions::new());
65+
notes.len() as Field
66+
}
67+
// docs:end:notes_of
68+
69+
// docs:start:burn
70+
#[private]
71+
fn burn(from: AztecAddress, token_id: Field) {
72+
assert(storage.minter.read().eq(context.msg_sender().unwrap()), "caller is not the authorized minter");
73+
74+
// from the NFTNote properties, selects token_id and compares it against the token_id to be burned
75+
let options = NoteGetterOptions::new().select(NFTNote::properties().token_id, Comparator.EQ, token_id).set_limit(1);
76+
let notes = storage.owners.at(from).pop_notes(options);
77+
assert(notes.len() == 1, "NFT not found");
78+
79+
NFTPunk::at(context.this_address())._mark_nft_exists(token_id, false).enqueue(&mut context);
80+
}
81+
// docs:end:burn
82+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// docs:start:nft_note_struct
2+
use dep::aztec::{
3+
macros::notes::note,
4+
protocol_types::{
5+
address::AztecAddress,
6+
traits::Packable,
7+
},
8+
oracle::random::random,
9+
};
10+
11+
#[derive(Eq, Packable)]
12+
#[note]
13+
pub struct NFTNote {
14+
owner: AztecAddress,
15+
randomness: Field,
16+
token_id: Field,
17+
}
18+
// docs:end:nft_note_struct
19+
20+
// docs:start:nft_note_new
21+
impl NFTNote {
22+
pub fn new(owner: AztecAddress, token_id: Field) -> Self {
23+
// The randomness preserves privacy by preventing brute-forcing
24+
NFTNote { owner, randomness: unsafe { random() }, token_id }
25+
}
26+
}
27+
// docs:end:nft_note_new
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[package]
2+
name = "nft_bridge"
3+
type = "contract"
4+
5+
[dependencies]
6+
aztec = { path = "../../../../noir-projects/aztec-nr/aztec" }
7+
NFTPunk = { path = "../nft" }
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// docs:start:bridge_setup
2+
use aztec::macros::aztec;
3+
4+
#[aztec]
5+
pub contract NFTBridge {
6+
use dep::aztec::{
7+
macros::{storage::storage, functions::{public, initializer, private}},
8+
protocol_types::{address::AztecAddress, address::EthAddress, hash::sha256_to_field},
9+
state_vars::{PublicImmutable},
10+
};
11+
use dep::NFTPunk::NFTPunk;
12+
13+
#[storage]
14+
struct Storage<Context> {
15+
nft: PublicImmutable<AztecAddress, Context>,
16+
portal: PublicImmutable<EthAddress, Context>,
17+
}
18+
19+
#[public]
20+
#[initializer]
21+
fn constructor(nft: AztecAddress) {
22+
storage.nft.initialize(nft);
23+
}
24+
25+
#[public]
26+
fn set_portal(portal: EthAddress) {
27+
storage.portal.initialize(portal);
28+
}
29+
// docs:end:bridge_setup
30+
31+
// docs:start:claim
32+
#[private]
33+
fn claim(to: AztecAddress, token_id: Field, secret: Field, message_leaf_index: Field) {
34+
// Compute the message hash that was sent from L1
35+
let token_id_bytes: [u8; 32] = (token_id as Field).to_be_bytes();
36+
let content_hash = sha256_to_field(token_id_bytes);
37+
38+
// Consume the L1 -> L2 message
39+
context.consume_l1_to_l2_message(
40+
content_hash,
41+
secret,
42+
storage.portal.read(),
43+
message_leaf_index
44+
);
45+
46+
// Mint the NFT on L2
47+
let nft = storage.nft.read();
48+
NFTPunk::at(nft).mint(to, token_id).call(&mut context);
49+
}
50+
// docs:end:claim
51+
52+
// docs:start:exit
53+
#[private]
54+
fn exit(
55+
token_id: Field,
56+
recipient: EthAddress
57+
) {
58+
// Create L2->L1 message to unlock NFT on L1
59+
let token_id_bytes: [u8; 32] = token_id.to_be_bytes();
60+
let recipient_bytes: [u8; 20] = recipient.to_be_bytes();
61+
let content = sha256_to_field(token_id_bytes.concat(recipient_bytes));
62+
context.message_portal(storage.portal.read(), content);
63+
64+
// Burn the NFT on L2
65+
let nft = storage.nft.read();
66+
NFTPunk::at(nft).burn(context.msg_sender().unwrap(), token_id).call(&mut context);
67+
}
68+
// docs:end:exit
69+
}

0 commit comments

Comments
 (0)