Build Your First NFT Collection on LUKSO

Bianca Buzea
LUKSO
Published in
12 min readMay 22, 2024

In this technical tutorial, you will learn how to create your first NFT on LUKSO, a cute collection of Munchkins, using the LSP8 standard! LSP8 is the LUKSO standard for non-fungible tokens (NFTs), offering extended functionalities such as rich metadata (including details about the creator and dynamic attributes that allow the NFT to evolve), notifications upon transfers, and adaptability to new requirements post-deployment.

We’ll start by setting up the project, writing the smart contract, and deploying it. Then, we will cover generating the JSON metadata for our assets, updating the tokenID metadata, and the minting process.

The code for the tutorial is located here.

One advantage of building on LUKSO is that it is EVM-compatible. This means that you will be able to use all the tools you love and are already familiar with.

The Components

1. LUKSO Development Environment

When building your project, you will need a way to test, debug, compile, and deploy your smart contracts, without dealing with live environments.

Hardhat is an EVM-compatible development environment and framework designed for full-stack development that we will use for this tutorial. However, you can also use other tools such as Foundry.

2. LUKSO Web Client Library

We will also need a way to interact with the smart contracts that have been deployed. To do that we will use ethers.js library. Alternatively, you can also use web3.js.

👷Build Time

For this tutorial, we’ll create a new folder called NFT-collection.

mkdir NFT-collection
cd NFT-collection

Next, we install ethers.js and hardhat using either NPM or Yarn:

npm install ethers hardhat @nomiclabs/hardhat-waffle

Inside the newly created folder, we create a new Hardhat project:

npx hardhat init

We will proceed with a TypeScript project, and confirm the rest of the options. After the initialization is complete, we will have the following main files and folders:

hardhat.config.js — The entirety of your Hardhat setup (i.e. your config, plugins, and custom tasks) is contained in this file.

scripts — A folder containing a script named sample-script.js that will deploy your smart contract when executed

test — A folder containing an example testing script

contracts — A folder holding an example Solidity smart contract

We will start by cleaning the starter code generated when initializing the project. First, we will delete the file Lock.sol under contracts. Don’t delete the actual folder, just the file. We will do the same for the test file under the test directory, and the Lock.tsfile under ignition/modules. Next, we will create a folder called assetswhere we will store the images for our NFTs, and the icon for our collection.

📝 Write our starter contract

We will start by creating the smart contract. We will create a new file under the contracts directory and call it Munchkins.sol, after the name of our collection.

For the smart contract, we will create a custom LSP8 — Identifiable Digital Asset that extends the LSP8Mintable present so that new assets can be minted by the owner of the smart contract. First, we “inherit” the LSP8Minatble using is LSP8Mintable when we declare the contract. Inheriting from LSP8Mintable allows us to leverage existing functionality and boilerplate code provided by the preset, which saves us time and ensures adherence to the LSP8 standard.

To set up our NFT collection, we pass through the constructor the name, ticker, and owner, as well as two LUKSO-specific constants, _LSP4_TOKEN_TYPE_NFT ( describes the type of token this digital asset contract represents, an NFT in this case), and _LSP8_TOKENID_FORMAT_NUMBER ( describes the format of tokenIds that the contract will create).

Now you might wonder what are tokenIds. The tokenIds offer unique identifiers for our digital assets. On LUKSO, creators have additional flexibility when it comes to tokenIds, as the tokenId is defined as bytes32 allowing different tokenId identification including numbers, contract addresses, or any other unique identifiers.

What about the Token Type?

When creating smart contracts representing digital assets on LUKSO, we need to specify the token type. There are three possible options: Token, NFT, and Collection.

  • 0 = Token
  • 1 = NFT
  • 2 = Collection
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.24;

// modules
import {LSP8Mintable} from "@lukso/lsp8-contracts/contracts/presets/LSP8Mintable.sol";

// constnats
import {_LSP8_TOKENID_FORMAT_NUMBER} from "@lukso/lsp8-contracts/contracts/LSP8Constants.sol";
import {_LSP4_TOKEN_TYPE_NFT} from "@lukso/lsp4-contracts/contracts/LSP4Constants.sol";

contract Munchkins is LSP8Mintable {
constructor(
string memory name,
string memory ticker,
address newOwner
)
LSP8Mintable(
name,
ticker,
newOwner,
_LSP4_TOKEN_TYPE_NFT,
_LSP8_TOKENID_FORMAT_NUMBER
)
{

// Any other desirable functions
}

}

Make sure to install the following libraries:

npm i @lukso/lsp8-contracts @lukso/lsp4-contracts

🤔 How do we run it?

Now that we have our smart contract, it is time to compile it to transform it into bytecode:

npx hardhat compile

We will modify the default hardhat.config.ts and update the config with the parameters for the LUKSO Testnet and Mainnet:

import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";

import { config as LoadEnv } from "dotenv";
LoadEnv();

const config: HardhatUserConfig = {
networks: {
lukso_testnet: {
chainId: 4201,
url: "https://rpc.testnet.lukso.gateway.fm",
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
},
lukso_mainnet: {
chainId: 42,
url: "https://rpc.lukso.gateway.fm",
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
},
},

solidity: {
version: "0.8.24",
settings: {
optimizer: {
enabled: true,
runs: 200,
},
},
},
};

export default config;

We are going to create a new folder, scripts. The first script we will use is the deploy.ts script. However, before that, we will create a .env file to define our secrets.

Create a .env file in the root directory, and define the following variables:

PUBLIC_KEY=0x.. // public key used for deployment
PRIVATE_KEY=0x.. // private key used for deployment
COLLECTION_OWNER=0x.. // Universal Profile address of the owner of the collection

To use dotenv, we will need to install the dotenv module first:

npm install dotenv --save

Now, back to our deploy.tsfile.

import { ethers } from "hardhat";
import { Munchkins__factory } from "../typechain-types";

import { config as LoadEnv } from "dotenv";
LoadEnv();

const { PUBLIC_KEY, COLLECTION_OWNER } = process.env;

const main = async () => {

if (!PUBLIC_KEY || !COLLECTION_OWNER) {
console.error("Missing environment variables. Please ensure PUBLIC_KEY and COLLECTION_OWNER are set in .env.");
return;
}

// Get signer using the public key from the environment variables
const signer = await ethers.getSigner(PUBLIC_KEY);

// Deploy the Munchkins contract
const collection = await new Munchkins__factory(signer).deploy(
"Munchkins",
"MNK",
COLLECTION_OWNER
);

console.log("Contract deployed to", await collection.getAddress());
};


main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

Let’s break down what is happening. We first load our secrets from the dotenv file. If either the public key or the owner is not set, we display an error message and stop execution.

import { config as LoadEnv } from "dotenv";
LoadEnv();

const { PUBLIC_KEY, COLLECTION_OWNER } = process.env;
if (!PUBLIC_KEY || !COLLECTION_OWNER) {
console.error("Missing environment variables. Please ensure PUBLIC_KEY and COLLECTION_OWNER are set in .env.");
return;
}

Otherwise, we retrieve the signer object associated with the given PUBLIC_KEY.

const signer = await ethers.getSigner(PUBLIC_KEY);

Now we are ready to deploy our collection. We will specify a name, a ticker, and we assign the ownership of the contract to the COLLECTION_OWNER. Then we print the address of the deployed smart contract to the console.

// Deploy the Munchkins contract
const collection = await new Munchkins__factory(signer).deploy(
"Munchkins",
"MNK",
COLLECTION_OWNER
);
console.log(await collection.getAddress());

Now that we have our deploy file ready, we can run it. We will simply run the script, and we add the network flag to indicate that we want to deploy our smart contract on the LUSKO Testnet.

npx hardhat run scripts/deploy.ts --network lukso_testnet

When we are done testing, and are ready to deploy on Mainnet, we will use instead:

npx hardhat run scripts/deploy.ts --network lukso_mainnet

Now, we will create another script under the scripts folder, generateMetadataJSON.ts. We will use it to generate the JSON metadata for our digital assets.

For each of the assets we will need to follow the following steps:

  1. Upload each media file (icon, pictures, etc.) and get their IPFS CID (if using a decentralized storage solution such as IPFS), or their url (if using a centralized data storage). We will use IPFS in this example.
  2. With the file hashes and IPFS CIDs (Content Identifiers), generate the final LSP4 Metadata JSON file and upload this file to get its IPFS CID.
  3. Encode the LSP4 Metadata JSON file URL as a VerifiableURI.
  4. Write the reference to the JSON file on the contract.

For step 1 we will upload our media to IPFS. We will use Thirdweb to easily upload our files using the Storage Dashboard. For each asset, we will take note of the CID, which we will use in the url parameter of each of the assets.

First, we will check whether there is already a metadata.jsonfile in the assetsfolder. If there is one, we show a message to the console and stop further execution.

 if (existsSync("assets/metadata.json")) {
console.log("`metadata.json` already exists in the assets folder.");
return;
}

Next, we will read the files. We will read each image from the specified path and convert its binary content into a hexadecimal string. We repeat this for all of our assets.

const firstImage = readFileSync(“assets/1.png”).toString(“hex”);

After this, we create the metadata for our collection. Here we can define a description, links, and different attributes that characterize the collection.

We will then declare an array with all of our images. For each image, we will declare a width and a height, the url that corresponds to the CID where it is stored on IPFS, and its hash (to be able to verify in the future that the image has not been tampered with).

 const images = [
[
{
width: 1024,
height: 1024,
url: "ipfs://QmVfYb9D6x5hNuAaMa1qmoS2LmGi9Z78B1JKKaMyedv7wv/Butter.png",
verification: {
method: "keccak256(bytes)",
hash: ethers.keccak256(`0x${firstImage}`),
},
},
],
[
{
width: 1024,
height: 1024,
url: "ipfs://QmZR5P4o322gTtAxsbt2gm93e7bYqBhnjaMog4HnjYmAFt/Sprinkle.png",
verification: {
method: "keccak256(bytes)",
hash: ethers.keccak256(`0x${secondImage}`),
},
},
],
];
const icon = [
{
width: 1024,
height: 1024,
url: "ipfs://QmPS3n3xe5gQsGpUnpaYWvzug44JknBZbUv6cv8yKJ7Gsi/icon.png",
verification: {
method: "keccak256(bytes)",
hash: ethers.keccak256(`0x${iconImage}`),
},
},
];

const json = {
LSP4Metadata: {
name,
description,
links,
attributes,
images,
icon,
assets: [],
},
};

writeFileSync("assets/metadata.json", JSON.stringify(json));
};

main();
import { existsSync, readFileSync, writeFileSync } from "fs";
import { ethers } from "ethers";

const main = () => {
if (existsSync("assets/metadata.json")) {
console.log("`metadata.json` already exists in the assets folder.");
return;
}

const firstImage = readFileSync("assets/Butter.png").toString("hex");
const secondImage = readFileSync("assets/Sprinkle.png").toString("hex");
const iconImage = readFileSync("assets/icon.png").toString("hex");

const name = "Munchkins";
const description = "A cute collection of Munchkins";
const links = [{ title: "Twitter", url: "https://twitter.com/" }];
const attributes = [
{ key: "Unrevealed", value: true, type: "boolean" },
{ key: "Background", value: "Yellow", type: "string" },
{ key: "Age", value: 10, type: "number" },
];
const images = [
[
{
width: 1024,
height: 1024,
url: "ipfs://QmVfYb9D6x5hNuAaMa1qmoS2LmGi9Z78B1JKKaMyedv7wv/Butter.png",
verification: {
method: "keccak256(bytes)",
hash: ethers.keccak256(`0x${firstImage}`),
},
},
],
[
{
width: 1024,
height: 1024,
url: "ipfs://QmZR5P4o322gTtAxsbt2gm93e7bYqBhnjaMog4HnjYmAFt/Sprinkle.png",
verification: {
method: "keccak256(bytes)",
hash: ethers.keccak256(`0x${secondImage}`),
},
},
],
];
const icon = [
{
width: 1024,
height: 1024,
url: "ipfs://QmPS3n3xe5gQsGpUnpaYWvzug44JknBZbUv6cv8yKJ7Gsi/icon.png",
verification: {
method: "keccak256(bytes)",
hash: ethers.keccak256(`0x${iconImage}`),
},
},
];

const json = {
LSP4Metadata: {
name,
description,
links,
attributes,
images,
icon,
assets: [],
},
};

writeFileSync("assets/metadata.json", JSON.stringify(json));
};

main();

Then, we create a json object to define the LSP4Metadata and its properties:

const json = {
LSP4Metadata: {
name,
description,
links,
attributes,
images,
icon,
assets: [],
},
};

Finally, we convert the json object to a JSON string using JSON.stringify and write it to a file named metadata.json in the assets directory.

writeFileSync("assets/metadata.json", JSON.stringify(json));
import { existsSync, readFileSync, writeFileSync } from "fs";
import { ethers } from "ethers";

const main = () => {
if (existsSync("assets/metadata.json")) {
console.log("`metadata.json` already exists in the assets folder.");
return;
}

const firstImage = readFileSync("assets/Butter.png").toString("hex");
const secondImage = readFileSync("assets/Sprinkle.png").toString("hex");
const iconImage = readFileSync("assets/icon.png").toString("hex");

const name = "Munchkins";
const description = "A cute collection of Munchkins";
const links = [{ title: "Twitter", url: "https://twitter.com/" }];
const attributes = [
{ key: "Unrevealed", value: true, type: "boolean" },
{ key: "Background", value: "Yellow", type: "string" },
{ key: "Age", value: 10, type: "number" },
];
const images = [
[
{
width: 1024,
height: 1024,
url: "ipfs://QmVfYb9D6x5hNuAaMa1qmoS2LmGi9Z78B1JKKaMyedv7wv/Butter.png",
verification: {
method: "keccak256(bytes)",
hash: ethers.keccak256(`0x${firstImage}`),
},
},
],
[
{
width: 1024,
height: 1024,
url: "ipfs://QmZR5P4o322gTtAxsbt2gm93e7bYqBhnjaMog4HnjYmAFt/Sprinkle.png",
verification: {
method: "keccak256(bytes)",
hash: ethers.keccak256(`0x${secondImage}`),
},
},
],
];
const icon = [
{
width: 1024,
height: 1024,
url: "ipfs://QmPS3n3xe5gQsGpUnpaYWvzug44JknBZbUv6cv8yKJ7Gsi/icon.png",
verification: {
method: "keccak256(bytes)",
hash: ethers.keccak256(`0x${iconImage}`),
},
},
];

const json = {
LSP4Metadata: {
name,
description,
links,
attributes,
images,
icon,
assets: [],
},
};

writeFileSync("assets/metadata.json", JSON.stringify(json));
};

main();

When running our script, we will see that a new file metadata.json will be created under the assets folder. Once we have it, we will upload the file to IPFS, and note its CID that we will use in our next script.

npx hardhat run scripts/generateMetadataJSON.ts --network lukso_testnet

Next, we will create another script under our scripts folder. We will call it updateTokenIdMetadata.ts.

First, we will check whether there is a COLLECTION_OWNER set in .env. If there is not one, we show a message to the console and stop further execution.

import { config as LoadEnv } from "dotenv";
LoadEnv();
if (!COLLECTION_OWNER) {
console.log("COLLECTION_OWNER is not set in .env")
return;
}

Then we get the signer for the COLLECTION_OWNER address:

const signer = await ethers.getSigner(COLLECTION_OWNER);

We then define a url variable where we store the CID from the metadata.jsonmeta that we uploaded in the previous step:

const url = "ipfs://QmammFxavKQp86RoQB9FkXVTcyTV4zouhfCutciKc6oJux/metadata.json";

After this, we read the local metadata.json file from the assets directory, convert it to a string, and then parse the string into a JSON object.

const json = JSON.parse(readFileSync("assets/metadata.json").toString());

Next, we encode the metadata using the using the erc725.js library- typically used for encoding and decoding data associated with smart contracts. The metadata includes the IPFS URL and the parsed JSON data.

const encodedMetadataURI = erc725.encodeData([
{
keyName: "LSP4Metadata",
value: {
url,
json,
},
},
]);

Next, we instantiate the collection address, with the address of our deployed smart contract, and tokenId. tokenId is the ID of the token, converted to a hexadecimal format with a length of 32 bytes using toBeHex(1, 32).

const collectionAddress = "0x06910205196C7393c1e37835A0b9F8EEbC7f30a1";
const tokenId = toBeHex(1, 32);

Next, we connect to the smart contract at collectionAddress using a factory Munchkins__factory and a signer.

const collection = Munchkins__factory.connect(
collectionAddress,
signer
);

Afterwards, we send a transaction to the smart contract to set data for a specific tokenId:

const tx = await collection.setDataForTokenId(
tokenId,
encodedMetadataURI.keys[0],
encodedMetadataURI.values[0]
);

Finally, we wait for the transaction confirmation. We retrieve the data associated with a specific token ID and log it to the console.

await tx.wait(1);

console.log(
await collection.getDataForTokenId(tokenId, encodedMetadataURI.keys[0])
);
import { readFileSync } from "fs";
import { ethers } from "hardhat";
import { toBeHex } from "ethers";
import { Munchkins__factory } from "../typechain-types";

import { config as LoadEnv } from "dotenv";
import { ERC725 } from "@erc725/erc725.js";
import LSP4DigitalAssetSchema from "@erc725/erc725.js/schemas/LSP4DigitalAsset.json";

const erc725 = new ERC725(LSP4DigitalAssetSchema);
LoadEnv();

const { COLLECTION_OWNER } = process.env;

const main = async () => {

if (!COLLECTION_OWNER) {
console.log("COLLECTION_OWNER is not set in .env")
return;
}

const signer = await ethers.getSigner(COLLECTION_OWNER);

const url =
"ipfs://QmammFxavKQp86RoQB9FkXVTcyTV4zouhfCutciKc6oJux/metadata.json";
const json = JSON.parse(readFileSync("assets/metadata.json").toString());

const encodedMetadataURI = erc725.encodeData([
{
keyName: "LSP4Metadata",
value: {
url,
json,
},
},
]);

const collectionAddress = "0x06910205196C7393c1e37835A0b9F8EEbC7f30a1";
const tokenId = toBeHex(1, 32);
const collection = Munchkins__factory.connect(
collectionAddress,
signer
);

const tx = await collection.setDataForTokenId(
tokenId,
encodedMetadataURI.keys[0],
encodedMetadataURI.values[0]
);

await tx.wait(1);

console.log(
await collection.getDataForTokenId(tokenId, encodedMetadataURI.keys[0])
);
};

main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

Now we are ready to run our script:

npx hardhat run scripts/updateTokenIdMetadata.ts --network lukso_testnet

The last script that we will create will handle the minting process, we will call it mintTokenId.ts.

The first part of the script is very similar to the scripts that we had before, we load our environment variables from .env, we check that there is a COLLECTION_OWNER defined, we get our signer by passing the COLLECTION_OWNER, we define our collectionAddress, and we connect to the smart contract using the factory Munchkins__factory and a signer.

We then define a recipient address that corresponds to the Universal Profile that will receive the minted asset as well as the TokenId, force and data parameters.

const to = "0x5bBdacCe1c93175F3953D84E6f2c9e9c2be1C205";
const tokenId = toBeHex(1, 32);
const force = false;
const data = "0x";

Finally, we mint the token by passing in four parameters (to, tokenId, force, data). We wait for the transaction confirmation and log to the console the address of the owner of the token.

const tx = await collection.mint(to, tokenId, force, data);

await tx.wait(1);

console.log(await collection.tokenOwnerOf(tokenId));
import { readFileSync } from "fs";
import { ethers } from "hardhat";
import { toBeHex } from "ethers";

import { Munchkins__factory } from "../typechain-types";
import { config as LoadEnv } from "dotenv";

LoadEnv();

const { COLLECTION_OWNER } = process.env;

const main = async () => {
if (!COLLECTION_OWNER) {
console.log("COLLECTION_OWNER is not set in .env");
return;
}

const signer = await ethers.getSigner(COLLECTION_OWNER);

const collectionAddress = "0x06910205196C7393c1e37835A0b9F8EEbC7f30a1";
//const collectionAddress ="0x0B9AAdd3C34DF45207c028bce69704E7FDdCB49d";
const collection = Munchkins__factory.connect(
collectionAddress,
signer
);

const to = "0x5bBdacCe1c93175F3953D84E6f2c9e9c2be1C205";
const tokenId = toBeHex(1, 32);
const force = false;
const data = "0x";

const tx = await collection.mint(to, tokenId, force, data);

await tx.wait(1);

console.log(await collection.tokenOwnerOf(tokenId));
};

main();

Now we are ready to run our last script:

npx hardhat run scripts/mintTokenId.ts --network lukso_testnet

It’s Time to Celebrate! 🎉

Our cute Munchkins collection is live. You can now check it on the wallet.universalprofile.cloud/.

Further Resources:

--

--