Links
Comment on page

Using Nestable and MultiAsset RMRK legos in zkSync

Example of Nestable & MultiAsset RMRK lego usage in zkSync.
This workshop examines the Nestable and MultiAsset RMRK legos in action and shows how they can be used to create a new next-generation NFT collection.
The easiest way of doing so, is by defining a use case utilizing both legos and then building a collection around it. So let's do just that!
The case studies utilizing v2.0.0 of our package are coming soon. The current examples are of the previous version.

Use Case

The use case we will be implementing in this workshop is a music album. The album will be a collection of NFTs, each representing a song in the album. Each song can be represented by multiple assets, such as an audio file, a lyric sheet, etc. The album itself will also be represented by a single NFT, which will be the parent of all the songs. The album NFT will also contain multiple assets such as album artwork, metadata about the album, such as the artist, the release date, etc.
We will use a fictitious artist called "The RMRKables" to demonstrate the use case. The album they released as NFTs is called "The RMRKables - Next Generation". And it will contain the following songs:
  • Into the Future
  • My Utility
  • Minbend
The album NFT will live in its own collection, while the songs will live in their own collection. The album NFT will contain the artwork to be displayed in the music applications and a json file containing the metadata about the album.
The song NFTs will contain the audio file, the lyric sheet, and the metadata about the song, such as the title, the artist, the duration, etc. They will be nested into the album NFT and form the whole.

Setup

We've prepared a template repository for you to get started with. It includes a simple Equippable smart contract, tests, deploy script and network configuration. Our smart contract development package, @rmrk-team/evm-contracts is included as well. You can find it here: https://github.com/rmrk-team/evm-template/tree/zksync.
The easiest way to get started is to fork the template repository and clone it to your local machine. You can do so by clicking the "Use this template" button on the template repository page.
NOTE: If you intend to use the template repository with the ZKSync network, make sure to select the Include all branches option and use the zksync branch.
Once you have the template repository cloned to your local machine, you can install the dependencies by running the following command:
yarn install
You can explore the template repository, compile the smart contract and run the tests by running the following commands:
yarn hardhat compile && yarn hardhat test
Once you are ready, you can remove the Equippable smart contract and the tests for it. You can also remove the deploy.js scrip as we will not be using them in this workshop:
rm contracts/SimpleEquippable.sol && rm test/equippable.ts && rm scripts/deploy.ts

Building the smart contracts

Now that we have the template repository set up, we can start building the smart contracts.

Album

We will start by creating a new smart contract for the Album NFT. We will call it Album.sol and place it in the contracts folder.
The skeleton of the smart contract will look like this:
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.18;
contract Album {
}
As the Album NFT will utilize the Nestable as well as MultiAsset lego, we will need to import them into the smart contract. In addition to the separate smart contracts for each lego, we also provide a unified smart contract that contains both. We will import the RMRKNestableMultiAsset.sol smart contract:
import "@rmrk-team/evm-contracts/contracts/RMRK/nestable/RMRKNestableMultiAsset.sol";
In addition to the RMRKNestableMultiAsset smart contract, we will also need to import the Ownable smart contract from OpenZeppelin. We will us it to provide simple access control to the smart contract:
import "@openzeppelin/contracts/access/Ownable.sol";
Now that we have the required smart contracts imported, we can inherit from them in our Album smart contract:
contract Album is RMRKNestableMultiAsset, Ownable {
}
To properly initialize the RMRKNestableMultiAsset smart contract, we need to pass the name_ and symbol_ parameters to the constructor. We will pass them, as constructor parameters:
constructor(
string memory name_,
string memory symbol_
) RMRKNestableMultiAsset(name_, symbol_) {}

Mint

We can now start implementing the functionality. We will start by implementing the mint function. The mint function will be used to mint the Album NFT. It will take the to address as a parameter and will mint the NFT to that address. It will also take the amount parameter, which will be an unsigned integer defining the amount of tokens to be minted at the same time.
In order to automatically increment the token IDs, we need to define a the total and maximum supply of the collection. To do so, we will define the totalSupply and maxSupply variables. We will make them public, so that we are able to use the auto-generated getters to retrieve their values:
uint256 public totalSupply;
uint256 public maxSupply;
As the initial totalSupply will be 0, we don't need to assign any value to it when deploying the smart contract. However, we will need to assign a value to the maxSupply variable. We will assign its value in the constructor using the maxSupply_ parameter. The updated constructor should look like this:
constructor(
string memory name_,
string memory symbol_,
uint256 maxSupply_
) RMRKNestableMultiAsset(name_, symbol_) {
maxSupply = maxSupply_;
}
The execution of the minting function should be reverted if attempting to mint 0 tokens, minting to 0x0 address or if minting the desired amount of tokens would exceed the maxSupply of the collection. If any of these conditions are met, the execution should be reverted with custom errors. To be able to use them, they need to be defined below the import statements:
error MintOverMaxSupply();
error ZeroAddress();
error ZeroAmount();
The _mint function of the RMRKNestableMultiAsset accepts the minting destination, the ID of the token to be minted and the data field (to which we will pass an empty value). To properly mint the desired amount of tokens, we need to make sure that the IDs don't overlap and that the total and maximum supply values are kept up to date. To do so, we will define a nextTokenId variable, which will be used to keep track of the next token ID to be minted. We will also update the total supply and use it to keep track of how many more tokens we can mint.
We also need to make sure that only the owner is allowed to call the mint function, so we need to include the onlyOwner modifier as well.
The mint function will look like this:
function mint(address to, uint256 amount) public onlyOwner {
if ( amount == 0 ) revert ZeroAmount();
if ( to == address(0) ) revert ZeroAddress();
if ( totalSupply + amount > maxSupply ) revert MintOverMaxSupply();
uint256 nextTokenId = totalSupply + 1;
unchecked {
totalSupply += amount;
}
uint256 totalSupplyOffset = maxSupply + 1;
for (uint256 i = nextTokenId; i < totalSupplyOffset; ){
_safeMint(to, i, "");
unchecked {
i++;
}
}
}
NOTE: You might have noticed that minting doesn't support token ID of 0. This is because the RMRK legos don't allow the use of the ID 0. This is a design decision allowing us to provide clean NFT nesting experience.
As the Album NFTs won't be nested, but will act as parent tokens, we don't need to implement nest transfers for this collection. We do however need to implement the functions to add the asset entries and add them to the tokens.

Add asset entry

We will add addAssetEntry function, which will be used to add the asset entries to the smart contract. One asset can be assigned to multiple tokens, so asset management is more streamlined than adding assets on a per-token level.
The internal _addAssetEntry function we will be using accepts ID of the asset to be added and the metadata URI of the asset. In order to simplify the management of asset IDs we will add a global numberOfAssets variable. We will define its visibility as public, so that we are able to use the auto-generated getter to retrieve its value when interacting with the smart contract:
uint64 public numberOfAssets;
Our addAssetEntry function now needs to accept the metadata URI pointing to the metadata of the asset. We will also need to make sure that only the owner is allowed to call the addAssetEntry function, so we need to include the onlyOwner modifier as well.
Similarly to the token IDs, the asset ID can't be 0, so we need to take this into account when adding the new asset entry.
The addAssetEntry function will look like this:
function addAssetEntry(string memory metadataURI) public onlyOwner {
unchecked {
numberOfAssets++;
}
_addAssetEntry(numberOfAssets, metadataURI);
}

Add asset to token

Once the asset entry is added to the smart contract, it can be added to the tokens. The internal function _addAssetToToken accepts the ID of the token to which the asset will be added, the ID of the asset to be added and the asset ID of the asset to be replaced by the asset being added. The asset ID of the asset to be replaced can be 0, which means that the asset will be added to the token, but won't replace an existing asset.
We will design addAssetToTokens in a way that accepts an array of token IDs that should receive the asset and the ID of the asset to be added. As we only intend to add assets to the tokens, we will hardcode the asset ID of the asset to be replaced to 0.
Since the assets need to be accepted once they are added to a token, we will streamline the process by accepting the asset if the token is owned by the owner of the smart contract. This will allow us to add the assets to the tokens without the need to accept them manually.
To check whether the token receiving the asset is owned by the owner of the smart contract, we will use the ownerOf function. The ownerOf function returns the root owner of the token, which means that if the token is nested, it will return the address of the owner of the parent token. This is the address we need to check against the owner of the smart contract. We can afford to make the assumption that the tokens aren't nested, as the Album NFTs won't be nested.
Another assumption we will be making is that the newly added asset resides in the last position of the pending array of the token. This is because the assets are added to the pending array in the order they are added to the token. This means that the asset being added will always be the last one in the pending array. An important distinction compared to token IDs to note is that the pending array is zero-indexed, so the last asset will have the index of length - 1.
Additionally only the owner should be allowed to call the addAssetToTokens function, so we will include the onlyOwner modifier as well.
The addAssetToTokens function will look like this:
function addAssetToTokens(uint256[] memory tokenIds, uint64 assetId) public onlyOwner {
for (uint256 i = 0; i < tokenIds.length; ) {
_addAssetToToken(tokenIds[i], assetId, 0);
if ( ownerOf(tokenIds[i]) == msg.sender ) {
uint256 assetIndex = getPendingAssets(tokenIds[i]).length - 1;
acceptAsset(tokenIds[i], assetIndex, assetId);
}
unchecked {
i++;
}
}
}
Click to see the full code of the Album smart contract
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.18;
import "@rmrk-team/evm-contracts/contracts/RMRK/nestable/RMRKNestableMultiAsset.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
error MintOverMaxSupply();
error ZeroAddress();
error ZeroAmount();
contract Album is RMRKNestableMultiAsset, Ownable {
uint256 public totalSupply;
uint256 public maxSupply;
uint64 public numberOfAssets;
constructor(
string memory name_,
string memory symbol_,
uint256 maxSupply_
) RMRKNestableMultiAsset(name_, symbol_) {
maxSupply = maxSupply_;
}
function mint(address to, uint256 amount) public onlyOwner {
if (amount == 0) revert ZeroAmount();
if (to == address(0)) revert ZeroAddress();
if (totalSupply + amount > maxSupply) revert MintOverMaxSupply();
uint256 nextTokenId = totalSupply + 1;
unchecked {
totalSupply += amount;
}
uint256 totalSupplyOffset = totalSupply + 1;
for (uint256 i = nextTokenId; i < totalSupplyOffset; ) {
_safeMint(to, i, "");
unchecked {
i++;
}
}
}
function addAssetEntry(string memory metadataURI) public onlyOwner {
unchecked {
numberOfAssets++;
}
_addAssetEntry(numberOfAssets, metadataURI);
}
function addAssetToTokens(
uint256[] memory tokenIds,
uint64 assetId
) public onlyOwner {
for (uint256 i = 0; i < tokenIds.length; ) {
_addAssetToToken(tokenIds[i], assetId, 0);
if (ownerOf(tokenIds[i]) == msg.sender) {
uint256 assetIndex = getPendingAssets(tokenIds[i]).length - 1;
acceptAsset(tokenIds[i], assetIndex, assetId);
}
unchecked {
i++;
}
}
}
}
With this, we have the minimum required functions to mint the Album NFTs. We can now move on to creating the Song NFTs smart contract.

Song

The Song NFTs will contain songs that are part of the album. The Song NFTs will be nested into the Album NFTs. We will call the smart contract for them Song.sol and place it in the contracts folder.
The skeleton of the smart contract will look like this:
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.18;
contract Song {
}
Just like the Album.sol, this smart contract will use the RMRKNestableMultiAsset.sol to gain access to the functionality of the Nestable and MultiAsset legos and Ownable for access control. They both need to be imported and set to be inherited by our smart contract:
import "@rmrk-team/evm-contracts/contracts/RMRK/nestable/RMRKNestableMultiAsset.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract Song is RMRKNestableMultiAsset, Ownable {
}
To properly initialize the RMRKNestableMultiAsset smart contract, we need to pass the name_ and symbol_ parameters to the constructor. We will pass them, as constructor parameters:
constructor(
string memory name_,
string memory symbol_
) RMRKNestableMultiAsset(name_, symbol_) {}

Nest mint

We can now start implementing the functionality. We will start by implementing the nestMint function. The nestMint function will be used to mint the Song NFTs directly into the Album NFTs. It will accept the to parameter, which will be the address of the Album NFT into which to mint the Song NFTs. It will also acccept the destinationId which will be the ID of the destination token and the amount which will represent the number of Song NFTs to mint into the Album NFT.
As the internal _nestMint function, which we will be using, accepts the ID of the token to be minted, we will define the totalSupply variable, to keep track of the number of the existing Song NFTs. We will define its visibility as public, so that we are able to use the auto-generated getter to retrieve its value when interacting with the smart contract. The Song NFTs won't have maximum supply defined, as we can mint as many Song NFTs as we want into the Album NFTs (maybe we could release additional songs as part of the album later on):
uint256 public totalSupply;
When using nestMint, we have to make sure the destination is not 0x0 address, that the destinationId is not 0 and that the amount is greater than 0. We will define custom errors for these checks below the import statements:
error DestinationZeroAddress();
error DestinationIdZero();
error AmountZero();
With the errors defined, we can now implement the nestMint function:
function nestMint(address to, uint256 destinationId, uint256 amount) public onlyOwner {
if ( to == address(0) ) revert DestinationZeroAddress();
if ( destinationId == 0 ) revert DestinationIdZero();
if ( amount == 0 ) revert AmountZero();
uint256 nextTokenId = totalSupply + 1;
unchecked {
totalSupply += amount;
}
uint256 totalSupplyOffset = totalSupply + 1;
for (uint256 i = nextTokenId; i < totalSupplyOffset; ){
_nestMint(to, i, destinationId, "");
unchecked {
i++;
}
}
}

Add asset entry

Just like the addAssetEntry of the Album.sol, the addAssetEntry of the Song.sol will be used to add the asset to the Song NFTs. As they have the same functionality, we can just copy-paste it from there (we will also need to add the numberOfAssets public variable):
uint64 public numberOfAssets;
...
function addAssetEntry(string memory metadataURI) public onlyOwner {
unchecked {
numberOfAssets++;
}
_addAssetEntry(numberOfAssets, metadataURI);
}

Add asset to tokens

Much like adding assets to Album NFTs, we will add the assets to the Song NFTs in a similar manner. We could slightly modify the addAssetToTokens function of the Album.sol to be able to add multiple assets at the same time. To do this we will modify the assetId parameter to be an array of asset IDs.
The addAssetToTokens function will look like this:
function addAssetToTokens(uint256[] memory tokenIds, uint64[] memory assetIds) public onlyOwner {
for (uint256 i = 0; i < tokenIds.length; ) {
for(uint256 j = 0; j < assetIds.length; ) {
_addAssetToToken(tokenIds[i], assetIds[j], 0);
if ( ownerOf(tokenIds[i]) == msg.sender ) {
uint256 assetIndex = getPendingAssets(tokenIds[i]).length - 1;
acceptAsset(tokenIds[i], assetIndex, assetIds[j]);
}
unchecked {
j++;
}
}
unchecked {
i++;
}
}
}

Restricting transfers

As the songs are a part of the album and they shouldn't be transferable separately, we will restrict the transfers of the Song NFTs. For this we will use one of the ERC-6454 implementations that we provide. You can find the list in the Soulbound directory of our smart contracts.
The Song NFTs will be minted directly to the albums, so we will use the simplest implementation of the ERC-6454, the RMRKSoulbound.sol. This implementation makes the token non-transferable as soon as it is minted. We will import it and set it to be inherited by our smart contract:
import "@rmrk-team/evm-contracts/contracts/RMRK/extension/soulbound/RMRKSoulbound.sol";
...
contract Song is RMRKNestableMultiAsset, RMRKSoulbound, Ownable {
...
Unfortunately inheriting RMRKSoulbound will require the _beforeTokenTransfer hook and supportsInterface to be overriden. We will override them as follows:
function _beforeTokenTransfer(
address from,
address to,
uint256 tokenId
) internal override(RMRKCore, RMRKSoulbound) {
RMRKSoulbound._beforeTokenTransfer(from, to, tokenId);
}
function supportsInterface(bytes4 interfaceId)
public
view
override(RMRKSoulbound, RMRKNestableMultiAsset)
returns (bool)
{
return
RMRKSoulbound.supportsInterface(interfaceId) ||
super.supportsInterface(interfaceId);
}
Click to see the full code of the Song smart contract
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.18;
import "@rmrk-team/evm-contracts/contracts/RMRK/nestable/RMRKNestableMultiAsset.sol";
import "@rmrk-team/evm-contracts/contracts/RMRK/extension/soulbound/RMRKSoulbound.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
error DestinationZeroAddress();
error DestinationIdZero();
error AmountZero();
contract Song is RMRKNestableMultiAsset, RMRKSoulbound, Ownable {
uint256 public totalSupply;
uint64 public numberOfAssets;
constructor(
string memory name_,
string memory symbol_
) RMRKNestableMultiAsset(name_, symbol_) {}
function nestMint(
address to,
uint256 destinationId,
uint256 amount
) public onlyOwner {
if (to == address(0)) revert DestinationZeroAddress();
if (destinationId == 0) revert DestinationIdZero();
if (amount == 0) revert AmountZero();
uint256 nextTokenId = totalSupply + 1;
unchecked {
totalSupply += amount;
}
uint256 totalSupplyOffset = totalSupply + 1;
for (uint256 i = nextTokenId; i < totalSupplyOffset; ) {
_nestMint(to, i, destinationId, "");
unchecked {
i++;
}
}
}
function addAssetEntry(string memory metadataURI) public onlyOwner {
unchecked {
numberOfAssets++;
}
_addAssetEntry(numberOfAssets, metadataURI);
}
function addAssetToTokens(
uint256[] memory tokenIds,
uint64[] memory assetIds
) public onlyOwner {
for (uint256 i = 0; i < tokenIds.length; ) {
for (uint256 j = 0; j < assetIds.length; ) {
_addAssetToToken(tokenIds[i], assetIds[j], 0);
if (ownerOf(tokenIds[i]) == msg.sender) {
uint256 assetIndex = getPendingAssets(tokenIds[i]).length -
1;
acceptAsset(tokenIds[i], assetIndex, assetIds[j]);
}
unchecked {
j++;
}
}
unchecked {
i++;
}
}
}
function _beforeTokenTransfer(
address from,
address to,
uint256 tokenId
) internal override(RMRKCore, RMRKSoulbound) {
RMRKSoulbound._beforeTokenTransfer(from, to, tokenId);
}
function supportsInterface(
bytes4 interfaceId
)
public
view
override(RMRKSoulbound, RMRKNestableMultiAsset)
returns (bool)
{
return
RMRKSoulbound.supportsInterface(interfaceId) ||
super.supportsInterface(interfaceId);
}
}
This concludes the implementation of the Song NFT smart contract. We can now use the prettier and typechain sctipts to format the code and generate the typechain bindings. We will also need to compile the smart contracts, so we will run the following commands:
yarn prettier && yarn hardhat compile --network zkSyncTestnet && yarn typechain

Deploying the smart contracts

As the smart contracts are ready, we can write the deployment script. We will create a new file in the deploy directory called deploy.ts.
We will import delay from hardhat-etherscan, Deployer from hardhat-zksync-deploy, HardhatRuntimeEnvironment from hardhat/types, run from hardhat, Album and Song from typechain-types, Wallet and utils from zksync-web3 and ethers from ethers. Additionally we will add the skeleton of the deployment function:
import { delay } from '@nomiclabs/hardhat-etherscan/dist/src/etherscan/EtherscanService';
import { Deployer } from '@matterlabs/hardhat-zksync-deploy';
import { HardhatRuntimeEnvironment } from 'hardhat/types';
import { run } from 'hardhat';
import { Album, Song } from '../typechain-types';
import { Wallet, utils } from 'zksync-web3';
import * as ethers from 'ethers';
export default async function (hre: HardhatRuntimeEnvironment) {
}
The deploy script will be comprised of three distinct sections:
  1. 1.
    Deploying the Album NFT smart contract
  2. 2.
    Deploying the Song NFT smart contract
  3. 3.
    Verifying the smart contracts in the chain explorer

Deploying the Album NFT smart contract

We will start by deploying the Album NFT smart contract. In order to be able to track the progress of the deployment, we will add a console.log statement. The private key stored in the PRIVATE_KEY environment variable will be used to generate the wallet, which will in turn be used to generate the deployer.
console.log('Deploying Album smart contract');
const wallet = new Wallet(process.env.PRIVATE_KEY || '');
const deployer = new Deployer(hre, wallet);
In order to be able to deploy the smart contract, we will need to get the Album artifact and prepare the arguments to be passed to the constructor. The deployment arguments will be stored in a variable, so that we can reuse it when verifying the smart contract. Additionally, we will estimate the deployment fee. This will allow us to deposit the funds to L2 within the script:
const albumArtifact = await deployer.loadArtifact('Album');
const albumArgs = ['Album', 'ALB', 10_000];
const albumDeploymentFee = await deployer.estimateDeployFee(albumArtifact, albumArgs);
Now we can add the L2 deposit logic, which allows us to automatically deposit funds required for deployment from L1 to L2 during the execution of the script. Here we will use the albumDeploymentFee that we calculated in the previous step. It is worth noting, that the wallet needs to have sufficient balance to make a deposit. Once the deposit is made, we have to make sure that it has been recorded in the L2 chain, before continuing the deployment script:
const albumDepositHandle = await deployer.zkWallet.deposit({
to: deployer.zkWallet.address,
token: utils.ETH_ADDRESS,
amount: albumDeploymentFee,
});
await albumDepositHandle.wait();
We can now log the album deployment fee to the console.;
const parsedAlbumFee = ethers.utils.formatEther(albumDeploymentFee.toString());
console.log(`The deployment is estimated to cost ${parsedAlbumFee} ETH`);
Everything is now ready to deploy the Album NFT smart contract:
const album = <Album>await deployer.deploy(albumArtifact, albumArgs);
await album.deployed();
console.log(`Album smart contract deployed to ${album.address}.`);

Deploying the Song NFT smart contract

With the Album NFT smart contract deployed, we can now deploy the Song NFT smart contract. As the process of deploying the Song NFT smart contract just slightly differs from the process of deploying the Album NFT smart contract, we can just reuse the code from the previous steps, modifying the deployment arguments to be in line with what Song NFT smart contract requires:
console.log('Deploying Song smart contract');
const songArtifact = await deployer.loadArtifact('Song');
const songArgs = ['Song', 'SNG'];
const songDeploymentFee = await deployer.estimateDeployFee(songArtifact, songArgs);
const songDepositHandle = await deployer.zkWallet.deposit({
to: deployer.zkWallet.address,
token: utils.ETH_ADDRESS,
amount: albumDeploymentFee,
});
await songDepositHandle.wait();
const parsedSongFee = ethers.utils.formatEther(songDeploymentFee.toString());
console.log(`The deployment is estimated to cost ${parsedSongFee} ETH`);
const song = <Song>await deployer.deploy(songArtifact, songArgs);
await song.deployed();
console.log(`Song smart contract deployed to ${song.address}.`);

Verifying the smart contracts in the chain explorer

All that remains after the smart contracts have been deployed is to verify them in the chain explorer. Before initiating the verification, we will add a delay of 20 seconds, to make sure the chain explorer is ready for the verification. We will use the verify task from hardhat-etherscan to do so. We will pass the address, constructor argument and smart contract for each of the smart contracts to the task:
await delay(20000);
console.log('Verifying smart contracts');
await run(`verify:verify`, {
address: album.address,
constructorArguments: albumArgs,
contract: 'contracts/Album.sol:Album',
});
await run(`verify:verify`, {
address: song.address,
constructorArguments: songArgs,
contract: 'contracts/Song.sol:Song',
});
NOTE: If you encounter an error reporting no network found when running the deployment script, add and set the GOERLI_URL environment variable.
Expand this section for the full deployment script
import { delay } from '@nomiclabs/hardhat-etherscan/dist/src/etherscan/EtherscanService';
import { Deployer } from '@matterlabs/hardhat-zksync-deploy';
import { HardhatRuntimeEnvironment } from 'hardhat/types';
import { run } from 'hardhat';
import { Album, Song } from '../typechain-types';
import { Wallet, utils } from 'zksync-web3';
import * as ethers from 'ethers';
export default async function (hre: HardhatRuntimeEnvironment) {
console.log('Deploying Album smart contract');
const wallet = new Wallet(process.env.PRIVATE_KEY || '');
// Create deployer object and load the artifact of the contract you want to deploy.
const deployer = new Deployer(hre, wallet);
const albumArtifact = await deployer.loadArtifact('Album');
const albumArgs = ['Album', 'ALB', 10_000];
// Estimate contract deployment fee
const albumDeploymentFee = await deployer.estimateDeployFee(albumArtifact, albumArgs);
// OPTIONAL: Deposit funds to L2
// Comment this block if you already have funds on zkSync.
const albumDepositHandle = await deployer.zkWallet.deposit({
to: deployer.zkWallet.address,
token: utils.ETH_ADDRESS,
amount: albumDeploymentFee,
});
// Wait until the deposit is processed on zkSync
await albumDepositHandle.wait();
// Deploy this contract. The returned object will be of a `Contract` type, similarly to ones in `ethers`.
const parsedAlbumFee = ethers.utils.formatEther(albumDeploymentFee.toString());
console.log(`The deployment is estimated to cost ${parsedAlbumFee} ETH`);
const album = <Album>await deployer.deploy(albumArtifact, albumArgs);
await album.deployed();
console.log(`Album smart contract deployed to ${album.address}.`);
console.log('Deploying Song smart contract');
const songArtifact = await deployer.loadArtifact('Song');
const songArgs = ['Song', 'SNG'];
const songDeploymentFee = await deployer.estimateDeployFee(songArtifact, songArgs);
const songDepositHandle = await deployer.zkWallet.deposit({
to: deployer.zkWallet.address,
token: utils.ETH_ADDRESS,
amount: albumDeploymentFee,
});
// Wait until the deposit is processed on zkSync
await songDepositHandle.wait();
const parsedSongFee = ethers.utils.formatEther(songDeploymentFee.toString());
console.log(`The deployment is estimated to cost ${parsedSongFee} ETH`);
const song = <Song>await deployer.deploy(songArtifact, songArgs);
await song.deployed();
console.log(`Song smart contract deployed to ${song.address}.`);
await delay(20000);
console.log('Verifying smart contracts');
await run(`verify:verify`, {
address: album.address,
constructorArguments: albumArgs,
contract: 'contracts/Album.sol:Album',
});
await run(`verify:verify`, {
address: song.address,
constructorArguments: songArgs,
contract: 'contracts/Song.sol:Song',
});
}
With the deploy script ready, we can now run it:
yarn hardhat deploy-zksync --network zkSyncTestnet

User journey

In order to better observe the interaction and the operation of RMRK-powered smart contracts, we will create a simple user journey. The user journey will be comprised of the following steps:
  1. 1.
    Deploying the Album and Song NFT smart contracts
  2. 2.
    Minting Album NFTs
  3. 3.
    Adding asset entries for the Album NFTs
  4. 4.
    Adding assets to the Album NFTs
  5. 5.
    Minting Song NFTs into the Album NFTs
  6. 6.
    Adding asset entries for the Song NFTs
  7. 7.
    Adding assets to the Song NFTs
As running the script would be wasteful in a public network, the script will be designed to be run in the Hardhat's emulated network. The user journey script will reside in the scripts directory and will be named user-journey.ts.
The empty skeleton of the script will look as follows:
async function main () {
}
main()
.then(() => process.exit(0))
.catch(error => {
console.error(error);
process.exit(1);
});

Deploying the Album and Song NFT smart contracts

We will start by deploying the Album and Song NFT smart contracts. In order to be able to track the progress of the deployment, we will add a console.log statement. Prior to deploying the smart contract, we will also generate the signers that will be used to deploy the smart contracts, and interact with them.
In order to be able to use ethers, we will need to import it:
import { ethers } from 'ethers';
Getting the signers and deploying the smart contracts should look like this:
console.log('Getting signers');
const [owner, user] = await ethers.getSigners();
console.log(`Owner: ${owner.address}`);
console.log(`User: ${user.address}`);
console.log('Deploying the smart contracts')
const Album = await ethers.getContractFactory('Album');
const Song = await ethers.getContractFactory('Song');
const album = await Album.deploy('Album', 'ALB', 10_000);
await album.deployed();
const song = await Song.deploy('Song', 'SNG');
await song.deployed();
console.log(`Album smart contract deployed to ${album.address}.`);
console.log(`Song smart contract deployed to ${song.address}.`);

Minting Album NFTs

Album NFTs will be minted to both, the owner and the user. This will allow us to compare the behavior of the smart contracts when the owner and the user are interacting with them.
In order to mint the Album NFTs, we will use the mint function of the Album NFT smart contract. We will mint 2 Album NFTs to the owner and 1 Album NFT to the user. We will also add a console.log statement to track the progress of the minting.
console.log('Minting Album NFTs');
await album.mint(owner.address, 2);
await album.mint(user.address, 1);
console.log(`Album balance of owner: ${await album.balanceOf(owner.address)}`);