An asset is a type of output for an NFT, usually a media file.
An asset can be an image, a movie, a PDF file, device config file... A multi-asset NFT is one that can output a different asset based on specific contextual information, e.g. load a PDF if loaded into a PDF reader, vs. loading an image in a virtual gallery, vs. loading hardware configuration in an IoT control hub.
An asset is NOT an NFT or a standalone entity you can reference. It is part of an NFT - one of several outputs it can have.
Every RMRK NFT has zero or more assets. When it has zero assets, the metadata is "root level". Any new asset added to this NFT will override the root metadata, making this NFT revealable.
NOTE: To dig deeper into the MultiAsset RMRK lego, you can also refer to the EIP-5773 that we published.
In this example we will examine the MultiAsset RMRK block using two examples:
- SimpleMultiAsset is a minimal implementation of the MultiAsset RMRK block.
- AdvancedMultiAsset is a more customizable implementation of the MultiAsset RMRK block.
Let's first examine the simple, minimal, implementation and then move on to the advanced one.
The SimpleMultiAsset
example uses the
RMRKMultiAssetImpl
.
It is used by importing it using the import
statement below the pragma
definition:
import "@rmrk-team/evm-contracts/contracts/implementations/nativeTokenPay/RMRKMultiAssetImpl.sol";
Once the RMRKMultiAssetImpl.sol
is imported into our file, we can set the inheritance of our smart contract:
contract SimpleMultiAsset is RMRKMultiAssetImpl {
}
We won't be passing all of the required parameters, to intialize RMRKMultiAssetImpl
contract, to the constructor,
but will hardcode some of the values. The values that we will pass are:
data
: struct type of argument providing a number of initialization values, used to avoid initialization transaction being reverted due to passing too many parameters
The parameters that we will hardcode to the initialization of RMRKMultiAssetImpl
are:
name
:string
type of argument representing the name of the collection will be set toSimpleMultiAsset
symbol
:string
type od argument representing the symbol of the collection will be set toSMA
collectionMetadata_
:string
type of argument representing the metadata URI of the collection will be set toipfs://meta
tokenURI_
:string
type of argument representing the base metadata URI of tokens will be set toipfs://tokenMeta
NOTE: The InitData
struct is used to pass the initialization parameters to the implementation smart contract. This
is done so that the execution of the deploy transaction doesn't revert because we are trying to pass too many arguments.
The InitData
struct contains the following fields:
[
erc20TokenAddress,
tokenUriIsEnumerable,
royaltyRecipient,
royaltyPercentageBps, // Expressed in basis points
maxSupply,
pricePerMint
]
NOTE: Basis points are the smallest supported denomination of percent. In our case this is one hundreth of a percent.
This means that 1 basis point equals 0.01% and 10000 basis points equal 100%. So for example, if you want to set royalty
percentage to 5%, the royaltyPercentageBps
value should be 500.
So the constructor of the SimpleMultiAsset
should look like this:
constructor(InitData memory data)
RMRKMultiAssetImpl(
"SimpleMultiAsset",
"SMA",
"ipfs://meta",
"ipfs://tokenMeta",
data
)
{}
The SimpleMultiAsset.sol should look like this:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.18;
import "@rmrk-team/evm-contracts/contracts/implementations/nativeTokenPay/RMRKMultiAssetImpl.sol";
contract SimpleMultiAsset is RMRKMultiAssetImpl {
// NOTE: Additional custom arguments can be added to the constructor based on your needs.
constructor(
uint256 maxSupply,
uint256 pricePerMint
) RMRKMultiAssetImpl(
"SimpleMultiAsset",
"SMA",
maxSupply,
pricePerMint,
"ipfs://meta",
"ipfs://tokenMeta",
msg.sender,
10
) {}
}
Let's take a moment to examine the core of this implementation, the RMRKMultiAssetImpl
.
It uses the RMRKRoyalties
, RMRKMultiAsset
, RMRKCollectionMetadata
and RMRKMintingUtils
smart contracts from
RMRK stack. To dive deeper into their operation, please refer to their respective documentation.
Two errors are defined:
error RMRKMintUnderpriced();
error RMRKMintZero();
RMRKMintUnderpriced()
is used when not enough value is used when attempting to mint a token and RMRKMintZero()
is
used when attempting to mint 0 tokens.
The RMRKMultiAssetImpl
implements all of the required functionality of the MultiAsset lego. It implements
standard NFT methods like mint
, transfer
, approve
, burn
,... In addition to these methods it also implements the
methods specific to MultiAsset RMRK lego:
addAssetToToken
addAssetEntry
totalAssets
tokenURI
updateRoyaltyRecipient
WARNING: The RMRKMultiAssetImpl
only has minimal access control implemented. If you intend to use it, make sure
to define your own, otherwise your smart contracts are at risk of unexpected behaviour.
The mint
function is used to mint parent NFTs and accepts two arguments:
to
:address
type of argument that specifies who should receive the newly minted tokensnumToMint
:uint256
type of argument that specifies how many tokens should be minted
There are a few constraints to this function:
- after minting, the total number of tokens should not exceed the maximum allowed supply
- attempting to mint 0 tokens is not allowed as it makes no sense to pay for the gas without any effect
- value should accompany transaction equal to a price per mint multiplied by the
numToMint
The addAssetToToken
is used to add a new asset to the token and accepts three arguments:
tokenId
:uint256
type of argument specifying the ID of the token we are adding asset toassetId
:uint64
type of argument specifying the ID of the asset we are adding to the tokenreplacesAssetWithId
:uint64
type of argument specifying the ID of the asset we are overwriting with the desired asset
The addAssetEntry
is used to add a new URI for the new asset of the token and accepts one argument:
metadataURI
:string
type of argument specifying the metadata URI of a new asset
The totalAssets
is used to retrieve a total number of assets defined in the collection.
The tokenURI
is used to retrieve the metadata URI of the desired token and accepts one argument:
tokenId
:uint256
type of argument representing the token ID of which we are retrieving the URI
The updateRoyaltyRecipient
function is used to update the royalty recipient and accepts one argument:
newRoyaltyRecipient
:address
type of argument specifying the address of the new beneficiary recipient
The deploy script for the SimpleMultiAsset
smart contract resides in the
deployMultiAsset.ts
.
The script uses the ethers
, SimpleMultiAsset
and ContractTransaction
imports. The empty deploy script should look like
this:
import { ethers } from "hardhat";
import { SimpleMultiAsset } from "../typechain-types";
import { ContractTransaction } from "ethers";
async function main() {
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Before we can deploy the parent and child smart contracts, we should prepare the constants that we will use in the script:
const pricePerMint = ethers.utils.parseEther("0.0001");
const totalTokens = 5;
const [owner] = await ethers.getSigners();
Now that the constants are ready, we can deploy the smart contract and log the address of the contract to the console:
const contractFactory = await ethers.getContractFactory(
"SimpleMultiAsset"
);
const token: SimpleMultiAsset = await contractFactory.deploy(
{
erc20TokenAddress: ethers.constants.AddressZero,
tokenUriIsEnumerable: true,
royaltyRecipient: ethers.constants.AddressZero,
royaltyPercentageBps: 0,
maxSupply: 1000,
pricePerMint: pricePerMint
}
);
await token.deployed();
console.log(`Sample contract deployed to ${token.address}`);
A custom script added to package.json
allows us to easily run the script:
"scripts": {
"deploy-multi-asset": "hardhat run scripts/deployMultiAsset.ts"
}
Using the script with npm run deploy-multi-asset
should return the following output:
npm run deploy-multi-asset
> @rmrk-team/[email protected] deploy-multi-asset
> hardhat run scripts/deployMultiAsset.ts
Compiled 47 Solidity files successfully
Sample contract deployed to 0x5FbDB2315678afecb367f032d93F642f64180aa3
With the deploy script ready, we can examine how the journey of a user using multi asset would look like using this smart contract.
The base of it is the same as the deploy script, as we need to deploy the smart contract in order to interact with it:
import { ethers } from "hardhat";
import { SimpleMultiAsset } from "../typechain-types";
import { ContractTransaction } from "ethers";
async function main() {
const pricePerMint = ethers.utils.parseEther("0.0001");
const totalTokens = 5;
const [ , tokenOwner] = await ethers.getSigners();
const contractFactory = await ethers.getContractFactory(
"SimpleMultiAsset"
);
const token: SimpleMultiAsset = await contractFactory.deploy(
{
erc20TokenAddress: ethers.constants.AddressZero,
tokenUriIsEnumerable: true,
royaltyRecipient: ethers.constants.AddressZero,
royaltyPercentageBps: 0,
maxSupply: 1000,
pricePerMint: pricePerMint
}
);
await token.deployed();
console.log(`Sample contract deployed to ${token.address}`);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
NOTE: We assign the tokenOwner
the second available signer, so that the assets are not automatically accepted when added
to the token. This happens when an account adding an asset to a token is also the owner of said token.
First thing that needs to be done after the smart contract is deployed it to mint the NFT. We will use the totalTokens
constant to specify how many tokens to mint:
console.log("Minting tokens");
let tx = await token.mint(tokenOwner.address, totalTokens, {
value: pricePerMint.mul(totalTokens),
});
await tx.wait();
console.log(`Minted ${totalTokens} tokens`);
const totalSupply = await token.totalSupply();
console.log("Total tokens: %s", totalSupply);
Now that the tokens are minted, we can add new assets to the smart contract. We will prepare a batch of transactions that will add simple IPFS metadata link for the assets in the smart contract. Once the transactions are ready, we will send them and get all of the assets to output to the console:
console.log("Adding assets");
let allTx: ContractTransaction[] = [];
for (let i = 1; i <= totalTokens; i++) {
let tx = await token.addAssetEntry(`ipfs://metadata/${i}.json`);
allTx.push(tx);
}
console.log(`Added ${totalTokens} assets`);
console.log("Awaiting for all tx to finish...");
await Promise.all(allTx.map((tx) => tx.wait()));
Once the assets are added to the smart contract we can assign each asset to one of the tokens:
console.log("Adding assets to tokens");
allTx = [];
for (let i = 1; i <= totalTokens; i++) {
// We give each token a asset id with the same number. This is just a coincidence, not a restriction.
let tx = await token.addAssetToToken(i, i, 0);
allTx.push(tx);
console.log(`Added asset ${i} to token ${i}.`);
}
console.log("Awaiting for all tx to finish...");
await Promise.all(allTx.map((tx) => tx.wait()));
After the assets are added to the NFTs, we have to accept them. We will do this by once again building a batch of transactions for each of the tokens and send them at the end:
console.log("Accepting token assets");
allTx = [];
for (let i = 1; i <= totalTokens; i++) {
// Accept pending asset for each token (on index 0)
let tx = await token.connect(tokenOwner).acceptAsset(i, 0, i);
allTx.push(tx);
console.log(`Accepted first pending asset for token ${i}.`);
}
console.log("Awaiting for all tx to finish...");
await Promise.all(allTx.map((tx) => tx.wait()));
NOTE: Accepting assets is done in a array that gets elements, new assets, appended to the end of it. Once the asset is accepted, the asset that was added last, takes its place. For example:
We have assets A
, B
, C
and D
in the pending array organised like this: [A
, B
, C
, D
].
Accepting the asset A
updates the array to look like this: [D
, B
, C
].
Accepting the asset B
updates the array to look like this: [A
, D
, C
].
Finally we can check wether the URI are assigned as expected and output the values to the console:
console.log("Getting URIs");
const uriToken1 = await token.tokenURI(1);
const uriFinalToken = await token.tokenURI(totalTokens);
console.log("Token 1 URI: ", uriToken1);
console.log("Token totalTokens URI: ", uriFinalToken);
With the user journey script concluded, we can add a custom helper to the package.json
to make
running it easier:
"user-journey-multi-asset": "hardhat run scripts/multiAssetUserJourney.ts"
Running it using npm run user-journey-multi-asset
should return the following output:
npm run user-journey-multi-asset
> @rmrk-team/[email protected] user-journey-multi-asset
> hardhat run scripts/multiAssetUserJourney.ts
Sample contract deployed to 0x5FbDB2315678afecb367f032d93F642f64180aa3
Minting tokens
Minted 5 tokens
Total tokens: 5
Adding assets
Added 5 assets
Awaiting for all tx to finish...
All assets: [
BigNumber { value: "1" },
BigNumber { value: "2" },
BigNumber { value: "3" },
BigNumber { value: "4" },
BigNumber { value: "5" }
]
Adding assets to tokens
Added asset 1 to token 1.
Added asset 2 to token 2.
Added asset 3 to token 3.
Added asset 4 to token 4.
Added asset 5 to token 5.
Awaiting for all tx to finish...
Accepting token assets
Accepted first pending asset for token 1.
Accepted first pending asset for token 2.
Accepted first pending asset for token 3.
Accepted first pending asset for token 4.
Accepted first pending asset for token 5.
Awaiting for all tx to finish...
Getting URIs
Token 1 URI: ipfs://metadata/1.json
Token totalTokens URI: ipfs://metadata/5.json
This concludes our work on the SimpleMultiAsset.sol
. We can now move on to examining the
AdvancedMultiAsset.sol
.
The AdvancedMultiAsset
smart contract allows for more flexibility when using the multi asset lego. It implements
minimum required implementation in order to be compatible with RMRK multi asset, but leaves more business logic
implementation freedom to the developer. It uses the
RMRKMultiAsset.sol
import to gain access to the Multi asset lego:
import "@rmrk-team/evm-contracts/contracts/RMRK/multiasset/RMRKMultiAsset.sol";
We only need name
and symbol
of the NFT in order to properly initialize it after the AdvancedMultiAsset
inherits it:
contract AdvancedMultiAsset is RMRKMultiAsset {
// NOTE: Additional custom arguments can be added to the constructor based on your needs.
constructor(
string memory name,
string memory symbol
)
RMRKMultiAsset(name, symbol)
{
// Custom optional: constructor logic
}
}
This is all that is required to get you started with implementing the Multi asset RMRK lego.
The minimal AdvancedMultiAsset.sol should look like this:
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.18;
import "@rmrk-team/evm-contracts/contracts/RMRK/multiasset/RMRKMultiAsset.sol";
contract AdvancedMultiAsset is RMRKMultiAsset {
// NOTE: Additional custom arguments can be added to the constructor based on your needs.
constructor(
string memory name,
string memory symbol
)
RMRKMultiAsset(name, symbol)
{
// Custom optional: constructor logic
}
}
Using RMRKMultiAsset
requires custom implementation of minting logic. Available internal functions to use when
writing it are:
_mint(address to, uint256 tokenId)
_safeMint(address to, uint256 tokenId)
_safeMint(address to, uint256 tokenId, bytes memory data)
In addition to the minting functions, you should also implement the burning, transfer and asset management functions if they apply to your use case:
_burn(uint256 tokenId)
_addAssetEntry(uint64 id, string memory metadataURI)
_addAssetToToken(uint256 tokenId, uint64 assetId, uint64 replacesAssetWithId)
transferFrom(address from, address to, uint256 tokenId)
Any additional functions supporting your NFT use case and utility can also be added. Remember to thoroughly test your smart contracts with extensive test suites and define strict access control rules for the functions that you implement.
Happy multiassetting! 🫧