Skip to content

Latest commit

 

History

History
524 lines (395 loc) · 18 KB

File metadata and controls

524 lines (395 loc) · 18 KB

MultiAsset

MultiAsset RMRK lego

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.

Abstract

In this example we will examine the MultiAsset RMRK block using two examples:

Let's first examine the simple, minimal, implementation and then move on to the advanced one.

SimpleMultiAsset

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 to SimpleMultiAsset
  • symbol: string type od argument representing the symbol of the collection will be set to SMA
  • collectionMetadata_: string type of argument representing the metadata URI of the collection will be set to ipfs://meta
  • tokenURI_: string type of argument representing the base metadata URI of tokens will be set to ipfs://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
    ) {}
}

RMRKMultiAssetImpl

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.

mint

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 tokens
  • numToMint: 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

addAssetToToken

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 to
  • assetId: uint64 type of argument specifying the ID of the asset we are adding to the token
  • replacesAssetWithId: uint64 type of argument specifying the ID of the asset we are overwriting with the desired asset

addAssetEntry

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

totalAssets

The totalAssets is used to retrieve a total number of assets defined in the collection.

tokenURI

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

updateRoyaltyRecipient

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

Deploy script

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

User journey

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.

AdvancedMultiAsset

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! 🫧