Creating NFTs on LUKSO: Step-by-Step Guide

Using LSP8 to recreate the CloneX collection

Samuel VIDEAU
DROPPS
16 min readJul 19, 2023

--

Prerequisites

In order to follow this guide, you must have basic solidity and javascript knowledge.

Links

Introduction

Aimed at helping developers build on LUKSO, this guide will walk you through the process of creating a Non-Fungible Token (NFT) collection using LSP8, the 8th Lukso Standard Proposal, which is an equivalent of Ethereum’s ERC721. We will model our contract based on the popular Ethereum NFT collection, CloneX by RTFKT.

The first section of this guide will detail the creation of the collection and the attachment of metadata. The second part will cover creating a public and private mint with a whitelist.

CloneX contract break-down

You can find the CloneX contract code here: CloneX (CloneX) Token Tracker | Etherscan

The mint

The first thing we can observe, is that the CloneX collection used a Randomizer contract (CloneXRandomizer | Address 0xffC4DBEACe02c578Cc189004C0AD25eeB8d8bA3f | Etherscan):

abstract contract CloneXRandomizer {
function getTokenId(uint256 tokenId) public view virtual returns(string memory);
}
...
CloneXRandomizer tokenAttribution = CloneXRandomizer(randomizerAddress);

This randomizer uses Chainlink to generate unique token IDs, providing a safeguard against targeted token ID exploits. However, since Chainlink is currently unavailable on LUKSO, we’ll resort to a straightforward token ID incrementation. This method is highly effective when paired with a reveal system, which discloses the metadata specifically after the minting process is completed.

For the purpose of this educational demonstration, I’ll include the metadata from the outset. Please note, this practice is not advisable in a real-world, mainnet scenario.

We also noted RTFKT uses a specific ERC1155 contract to handle the mints (RTFKT: RTFKT Token | Address 0x348fc118bcc65a92dc033a951af153d14d945312 | Etherscan):

function mintTransfer(address to) public returns(uint256) {
require(msg.sender == mintvialAddress, "Not authorized");

In our guide, we’ll process this directly in the LSP8 contract.

The metadata

As with most NFT collections, RTFKT uses a baseURI to handle the metadata of the NFTs:

string public _tokenUri = "https://clonex-assets.rtfkt.com/";

Utilizing a baseURI in NFT collections provides a structured, consistent method for storing and referencing NFT metadata online, while enhancing efficiency by allowing you to simply add a unique identifier to the baseURI for each NFT, instead of crafting individual URLs.

Importantly, this practice can also significantly reduce gas fees, as less data needs to be stored on the blockchain.

You can try appending tokenIds (1–10000) to the URI, and pasting it in your browser, it should download the associated metadata file: https://clonex-assets.rtfkt.com/1

In our contract, we will cover two ways of storing the token’s metadata, but we will use the baseURI for our final version.

Let’s build

Initialization

For this guide, we’ll create a new repository named lukso-clonex:

mkdir lukso-clonex
cd lukso-clonex

Inside the newly created folder, run the following commands:

npm init
npm i --save-dev hardhat @lukso/lsp-smart-contracts@0.10.2 @openzeppelin/contracts@4.9.2 dotenv @nomiclabs/hardhat-ethers @nomiclabs/hardhat-etherscan

Notice we installed the version 0.10.2 of the LUKSO smart contracts, meaning some of the information in this article might not be accurate for newer versions.

Now that all the dependencies are installed, you can initialize hardhat.

npx hardhat

Select Create an empty hardhat.config.js.

Creation of the collection

Now let’s create and place our LuksoCloneX.sol file inside a contracts folder and paste the following code:

pragma solidity ^0.8.4;

import {LSP8IdentifiableDigitalAsset} from "@lukso/lsp-smart-contracts/contracts/LSP8IdentifiableDigitalAsset/LSP8IdentifiableDigitalAsset.sol";

contract LuksoCloneX is LSP8IdentifiableDigitalAsset {
constructor(address owner_) LSP8IdentifiableDigitalAsset('LuksoCloneX', 'lCloneX', owner_) {}
}

That’s it, you now have your LSP8 collection on LUKSO!

Let’s break down what’s happening there.

We started by importing the LSP8IdentifiableDigitalAsset contract (LUKSO’s improvement over Ethereum’s ERC721 standard).

We then inherited the LuksoCloneX contract from the above mentioned LSP8 contract and added a constructor.

With this constructor, all we have is the owner_ parameter, which is the address that will have ownership over the contract. In here, we’ll just use our EOA (Externally Owned Account) , but for real-world applications this address’ security should be highly prioritised (i.e. Multi-sig, Universal Profile with a secure layer of permission).

Apart from this parameter, we’re also passing the name and symbol of the collection to the LSP8 contract constructor, which if you follow the constructors, should lead to the following code:

super._setData(_LSP4_TOKEN_NAME_KEY, bytes(name_));
super._setData(_LSP4_TOKEN_SYMBOL_KEY, bytes(symbol_));

This is where things start to get “pink pilled”.

_setData is an internal method that sets data key/value pairs in a ERC725Y store: a generic data storage standard, used to provide LSPs with dynamic on-chain data storage space, that can be read, decoded and interpreted.

mapping(bytes32 => bytes) internal _store;

The ERC725Y data key/value store

To understand how data is stored on ERC725Y, I strongly recommend reading up on the following standard: LIPs/LSPs/LSP-2-ERC725YJSONSchema.md at main · lukso-network/LIPs · GitHub.

Why not adding more collection metadata?

Our collection now has a name and a symbol, but a collection should also have a banner, a description, etc. That’s what collections have on OpenSea, right ?

Yes, and no… You can indeed see a description, banner, image along with other information on OpenSea or equivalent marketplaces. However, this metadata is being manually added to Opensea, and not to the smart contracts. Using LUKSO-based standards, you can add an unlimited amount of data to your contracts, so let’s add a bit more information:

It might have eluded you, but we’ve already used the [LSP4] standard internally to set the name and symbol of the collection (recheck the constructor). This standard allows us to describe a predifined set of information (metadata) for digital assets (NFTs, tokens, etc), which we’ll use to add metadata to our collection.

I created the following repository, where you can deploy your own metadata using Pinata:

GitHub — samuel-videau/lukso-metadata-uploader

For this guide we uploaded the following metadata file here:

{
"LSP4Metadata": {
"description": "🧬 CLONE X - LUKSO = 🧬20,000 next-gen Avatars, by RTFKT and Takashi Murakami 🌸",
"links": [
{
"title": "DROPPS",
"url": "<https://dropps.io>"
},
{
"title": "RTFKT",
"url": "<https://rtfkt.com/>"
}
],
"icon": [
{
"width": 256,
"height": 252,
"hashFunction": "keccak256(bytes)",
"hash": "0x20ac942770c18ffcb0fe58e21d53ae36debd3d4f32bbf334fe07bc6f6041c0db",
"url": "ipfs://QmbwaMGKM3dTTWzx37yZYh5xn7n8NLHfAkBjTZqjqtXGro"
},
],
"images": [
[
{
"width": 1800,
"height": 363,
"hashFunction": "keccak256(bytes)",
"hash": "0x0b25fe93afa1acddffbca692cd7143892542e92ba33f8a4265571fa4d1ab6c61",
"url": "ipfs://QmaRAJsWMafXvvJgEXVSh9jyVkSypxHeFnCsAm7Nzg76Bi"
}
]
],
"assets": []
}
}

Now that we have our IPFS URI : ipfs://QmR6P52FwU8vVQRjqdi1AjHroVQRx35AzgFdTB3A9Jz5dx, what do we do with it ?

We’ll add a new data key/value pair in our LSP8 contract:

// ... Other imports
import {_LSP4_METADATA_KEY} from "@lukso/lsp-smart-contracts/contracts/LSP4DigitalAssetMetadata/LSP4Constants.sol";

constructor(address owner_) LSP8IdentifiableDigitalAsset('LuksoCloneX', 'lCloneX', owner_) {

// 4ed94534e6e56a4cf8cea54daa9fc9b59751668459433f8dba993763d5dc6e20 is the hash of the URI JSON content
bytes memory jsonUrl = abi.encodePacked(
bytes4(keccak256('keccak256(utf8)')),
hex"4ed94534e6e56a4cf8cea54daa9fc9b59751668459433f8dba993763d5dc6e20",
bytes('ipfs://QmR6P52FwU8vVQRjqdi1AjHroVQRx35AzgFdTB3A9Jz5dx'));

_setData(_LSP4_METADATA_KEY, jsonUrl);
}

We’ve now taken the bytes32 LSP4 metadata key constant (obtained by doing keccak256(”LSP4Metadata”)), and assigned to it the JSONURL value of our IPFS URI.

What is a JSONURL value? It’s a packed value containing 3 things: a hash function (in this case keccak256(utf8)), the hashed value of the content obtained with the hash function, and finally the URI encoded in bytes. This provides verifiability over off-chain data being referenced on-chain.

More about it there: LIPs/LSPs/LSP-2-ERC725YJSONSchema.md at main · lukso-network/LIPs (github.com)

Ok, and what about the NFTs metadata?

As we upscale our operation, we now have to deal with the metadata for the 100 NFTs we’re gonna create (the first 100 tokens from CloneX).

The first issue we’re facing is that RTFKT and all major Ethereum NFT collections are following OpenSea’s metadata standard: Metadata Standards (opensea.io). However, the one we’ll be following is the one proposed by LUKSO with the LSP4DigitalAssetMetadata standard , and it is quite different.

For convenience, I created a separate script in https://github.com/samuel-videau/lukso-metadata-uploader so we can convert the OpenSea standard to LSP4, so feel free to use it!

If you decide to create your own collection, you’ll have to do it yourself: one file per token (sorry!), following the LSP4 standard. Each file should be named using the tokenid only. In our case: “1”, “2”, “3”, etc.

Now that we have all of our metadata, we need to upload it to IPFS.

And again… I created a script to upload a folder to IPFS using Pinata in my https://github.com/samuel-videau/lukso-metadata-uploader repository. You just need to replace the files in the output folder by the files you want to upload, and run npm run upload-output-folder.

We now have our baseURI:

ipfs://QmZh7P3YZNxFZUiHkXLNgAtdk2T6PAza3S15Jjg1DzxVGf

You can try it out for the tokenId 43 (or any other): https://gateway.pinata.cloud/ipfs/QmZh7P3YZNxFZUiHkXLNgAtdk2T6PAza3S15Jjg1DzxVGf/43

And now that we have all our metadata on IPFS, it’s time to add some more code to our smart contracts.

I’ll show you two ways of doing it.

First method (used for the final contract):

The first method we’ll talk about is the baseURI method. This method is currently used by most of the NFT collections on Ethereum, as it’s simple and gas efficient, although it is ultimately up to each use case. This method is mostly used for fixed metadata collections, since the metadata is kept on a decentralized storage protocol like IPFS or ARWEAVE. Indeed, once it’s deployed on IPFS or ARWEAVE, your baseURI becomes a sealed box — no chance to edit or pop in new files. The exception to the rule is if your baseURI is in a centralized storage setup, allowing you to tweak and change the data.

To add a baseURI on an LSP8 collection, we’re gonna use the LSP8TokenMetadataBaseURI key (detailed here). Let’s add the following lines in our code:

// Before the contract declaration
bytes32 constant _LSP8_TOKEN_ID_TYPE = 0x715f248956de7ce65e94d9d836bfead479f7e70d69b718d47bfe7b00e05b4fe4;
bytes32 constant _LSP8_TOKEN_METADATA_BASE_URI = 0x1a7628600c3bac7101f53697f48df381ddc36b9015e7d7c9c5633d1252aa2843;
...
// In the constructor
_setData(_LSP8_TOKEN_ID_TYPE, hex"02");

bytes memory zeroBytes = hex"00000000";
bytes memory baseURI = abi.encodePacked(zeroBytes, bytes('ipfs://QmZh7P3YZNxFZUiHkXLNgAtdk2T6PAza3S15Jjg1DzxVGf'));
_setData(_LSP8_TOKEN_METADATA_BASE_URI, baseURI);

So what did we do here ?

First, we declared the constants for the 2 bytes32 keys we’ll be using.

Then, we set the value of the first key, token id type, to 2, as this number represents a tokenId of type uint256. If you’re wondering why we’d need to specify the type of the tokenId, it’s because tokenIds’ are always stored as bytes32(hexadecimal values), so we need to know how to interpret them (e.g. 12 in hexadecimal is c). You can find all the different tokenId types there: LSP8 - TokenIdType

We’ve now added the baseURI to the store. You might have missed it, but we also added hex”00000000” in front of our URI: that’s a slot that can be used when we want to add verifiability to our metadata by using the tokenId as a hash (hashFunction(<metadataContent>)). This bytes4 slot tells us which hash function has been used to hash the token metadata content (e.g. keccak256 = 0x6f357c6a). In our case, we’re using an unhashed token id so we won’t hash the content. Since we don’t need to have a hash function, we’ll just fill this slot with zeros.

Second method (not used in the final contract):

This second method consists of setting a different url for each token. Although it is less gas-efficient, it adds a lot of flexibility, and seems like the perfect fit for evolutive and dynamic collections (new NFTs being created over time), as the metadata is not fixed. Note that if both methods are used simultaneously, the second one would be prioritized over the first: if a baseURI is in the contract, but a token has it’s own URI, front-ends should prioritize the token URI.

If we wanted to add the metadata to all our tokens using this second method, it would require us to set data 100 times (which we don’t want to do), so for this example we’ll only set data with the LSP8MetadataTokenURI key for 3 tokens:

_setData(0x4690256ef7e93288012f00000000000000000000000000000000000000000001, abi.encodePacked(zeroBytes, bytes('ipfs://QmZh7P3YZNxFZUiHkXLNgAtdk2T6PAza3S15Jjg1DzxVGf/1')));
_setData(0x4690256ef7e93288012f00000000000000000000000000000000000000000002, abi.encodePacked(zeroBytes, bytes('ipfs://QmZh7P3YZNxFZUiHkXLNgAtdk2T6PAza3S15Jjg1DzxVGf/2')));
_setData(0x4690256ef7e93288012f00000000000000000000000000000000000000000003, abi.encodePacked(zeroBytes, bytes('ipfs://QmZh7P3YZNxFZUiHkXLNgAtdk2T6PAza3S15Jjg1DzxVGf/3')));

Maybe you’ve noticed a clear difference by now, compared to the other setData we’ve previously added: here we’re using a Mapping key, whereas before we used a Singleton . It’s basically a dynamic key that includes the tokenId: "LSP8MetadataTokenURI:<address|uint256|bytes32|string>"

If you want to learn more about the ERC725Y data structure, I again strongly advise you to read this: LIPs/LSPs/LSP-2-ERC725YJSONSchema.md at main · lukso-network/LIPs · GitHub.

One last thing, the creators credits

It’s always good crediting creators, so let’s do it with a another feature from LSP4: LSP4Creators[].

// We set the length of the array to 1
_setData(_LSP4_CREATORS_ARRAY_KEY, hex"01");

// We set the first element of the array to the creator address
_setData(0x114bd03b3a46d48759680d81ebb2b41400000000000000000000000000000000, hex"5870dC9aEB06E26A0C8130eF2C6C12d80b1E0375");

// Set the creator map with interfaceId=0x66767497 and creator index 0
bytes32 creatorsMapKey = bytes32(abi.encodePacked(_LSP4_CREATORS_MAP_KEY_PREFIX, 0x5870dC9aEB06E26A0C8130eF2C6C12d80b1E0375));
_setData(creatorsMapKey , hex"667674970000000000000000");

In the constructor, we’ve added 3 new entries to our data key/value store:

  • The first being the length of the creators array. Here it’s 1, but it could be 2, 3, 4… depending on the number of creators.
  • The first array element: the key is a mix of the keccak key (value of the _LSP4_CREATORS_ARRAY_KEY constant), and the element index (here 0). The value is simply the address of the creator (here I’m using my testnet Universal Profile)
  • The last element is a mapping to indicate the interfaceId (ERC165) and the index (in the array) of a creator (0x66767497stands for LSP0 Universal Profile v0.8)

I am now publicly referenced on-chain as the collection creator.

Let’s mint!

We now have all our 100 tokens ready to be minted! Let’s make it fun by creating a public and private mint along a whitelist system.

First things first, the mint function

Let’s add a simple public mint function to our contract:

function publicMint(
address to,
uint256 amount,
bool allowNonLSP1Recipient
) external {
uint256 tokenSupply = totalSupply(); // gas saving
for (uint256 i = 0; i < amount; i++) {
uint256 tokenId = ++tokenSupply;
_mint(to, bytes32(tokenId), allowNonLSP1Recipient, "");
}
}

You can now mint your tokens!

To better explain, we mint the tokens in a loop, converting the current token index (acquired from the total supply) to bytes32 values. Remember, tokenIds are bytes32 in LSP8.

You probably noticed the allowNonLSP1Recipient parameter. It’s there to verify whether we agree on minting the tokens to an address that is not an LSP1 contract (Universal Receiver), and to prevent having our tokens minted to the wrong address. If the value is set to true, the transaction should revert if you try to mint to any other address/contract.

So… the NFTs are free ?

They could be, but no, let’s create a bunch of LYXt instead.

uint256 constant PUBLIC_PRICE_PER_TOKEN = 0.2 ether;
...
function publicMint(
address to,
uint256 amount,
bool allowNonLSP1Recipient
) external payable {
...
require(msg.value == PUBLIC_PRICE_PER_TOKEN * amount, "Invalid LYX amount sent");
... // Mint
}

The NFTs now cost 0.2LYX: this price is defined in the PUBLIC_PRICE_PER_TOKEN constant. We added the payable identifier in the function declaration, to indicate it can accept payments. At the beginning of the function, we verify if the exact amount was sent: if not, the transaction will revert.

Let’s add more validations:

uint256 constant MAX_SUPPLY = 100;
uint256 constant MINT_END_BLOCK = 2_000_000;

mapping (address => uint256) private _mintedTokensPerAddress;
...
function publicMint(
address to,
uint256 amount,
bool allowNonLSP1Recipient
) external payable {
uint256 tokenSupply = totalSupply(); // gas saving

require(block.number <= MINT_END_BLOCK, "Mint ended");
require(tokenSupply + amount <= MAX_SUPPLY, "Exceeds MAX_SUPPLY");
... // Mint
}

We’ve added 2 simple checks here:

  • We verify if the public mint is still open (we’ve set the end block as 2000000; we will deploy on the LUKSO testnet)
  • We verify if the max supply has been reached (100 tokens in our case)

And we’re missing one last thing: a bit of security.

import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol";
...
contract LuksoCloneX is LSP8IdentifiableDigitalAsset, ReentrancyGuard {
...
function publicMint(
address to,
uint256 amount,
bool allowNonLSP1Recipient
) external payable nonReentrant {

We imported the ReentrancyGuard library to prevent reentrancy attacks (learn more about reentrancy attacks). We made our collection “inherit” from it, and added the nonReentrant modifier to the mint function.

Our Public Mint is now ready to be shipped.

What about a private mint?

As private sales/mints are somewhat recurrent, let’s take a quick look at what these entail. If you’re wondering what a private mint is, it’s simply a mint with limited access (generally using a whitelist).

To start, we’re gonna add a private mint function to our contract. The private mint will take place before the public mint, so whitelisted addresses have priority access.

Let’s create the whitelist:

mapping (address => uint256) private whitelistAllowance;

function setWhitelistAllowances(
address[] calldata accounts,
uint256[] calldata allowances
) external onlyOwner {
uint256 length = accounts.length; // Gas Saving
require(length == allowances.length, "Accounts and allowances arrays must have the same length");

for (uint256 i = 0; i < length; i++) {
whitelistAllowance[accounts[i]] = allowances[i];
}
}

We’ll use this function to give minting rights to whitelisted addresses (just kidding). Although it’s doable for a small collection, applying this to most use cases would make them prohibitively expensive. If your whitelist includes 3000 addresses (standard for a regular NFT collection), it would cost an obscene amount of gas.

What is a merkle tree?

You may have already heard about merkle trees, as they’re a fundamental part of blockchain technology. A Merkle tree is a binary tree of hashes, facilitating data verification through a subset of the tree, or a Merkle proof. It’s instrumental in smart contracts and whitelists, as it compacts extensive data sets into one hash and verifies whether an element is part of a set without revealing it, ensuring privacy. Thus, by storing the Merkle tree root in our smart contract, we can validate claims of being whitelisted using the provided Merkle proof.

As you’ve probably figured out by now, we’re gonna use it for our whitelist.

Whitelisted addresses selection

Each project has their own conditions for whitelisted addresses. In our case, I’ll select all the addresses that have interacted with the LUKSO testnet so far. I get them by using an indexing tool that we’re internally developing at DROPPS: https://indexing.testnet.dropps.io/graphql).

Now for the query:

SELECT address FROM contract;

And that’s it:

If you’re part of the 2287 addresses that interacted with the LUKSO testnet before 24th of June, 2023, you’ll maybe have the chance to mint a worthless NFT at a discounted price on the testnet (lucky you!).

Merkle tree creation

Now it’s time to create a merkle tree, for which I created a new script in your favorite repository: https://github.com/samuel-videau/lsp8-collection-helpers

To break it down:

We used the merkletreejs nodejs library to generate a merkle tree based on all the addresses from a whitelist.txt file we extracted from the database. We then created and uploaded on IPFS a JSON object containing all the whitelisted addresses, with their merkle proofs. A Merkle proof is a cryptographic proof of membership that demonstrates the existence of a specific data element in a Merkle tree. We’ll need those, in addition to the root stored on-chain, to prove we’re part of the whitelist.

Here it is, our merkle tree stored on IPFS:

ipfs://QmWhGiFziLhxiQNftSqBqeLCpSxCeFrHSbTN72N5r4E7yb

Link here

Note you can also customize the data stored in the merkle tree. Here we only hashed the addresses, but you could add any data with it, such as an allowance amount, a deadline, etc.

To do that, you’ll need to change the leaf calculation (hashed value) by adding data to it, e.g:

keccak256(<address>,<amount>,<deadline>);

On-chain merkle verification

Now, let’s use this merkle tree in our contract!

import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
...
uint256 constant PRIVATE_PRICE_PER_TOKEN = 0.1 ether;
uint256 constant PRIVATE_MINT_END_BLOCK = 2_000_000;
uint256 constant MAX_MINT_PER_WHITELISTED_ADDRESS = 3;
...
bytes32 constant _merkleRoot = 0x9247fd44ab1e9cbaa8bb670ba0313dc5a8d881a17feff76643b2ca7e6504a23f;

mapping (address => uint256) private _mintedTokensPerWhitelistedAddress;
...
function publicMint(
address to,
uint256 amount,
bool allowNonLSP1Recipient
) external payable nonReentrant {
...
require(block.number > PRIVATE_MINT_END_BLOCK, "Public mint not started yet");
...
}
...
function privateMint(
address to,
uint256 amount,
bool allowNonLSP1Recipient,
bytes32[] calldata merkleProof
) external payable nonReentrant {
uint256 tokenSupply = totalSupply(); // gas saving

require(MerkleProof.verify(merkleProof, _merkleRoot, keccak256(abi.encodePacked(msg.sender))), "Invalid merkle proof");
require(msg.value == PRIVATE_PRICE_PER_TOKEN * amount, "Invalid LYX amount sent");
require(block.number <= PRIVATE_MINT_END_BLOCK, "Private mint ended");
require(tokenSupply + amount <= MAX_SUPPLY, "Exceeds MAX_SUPPLY");
require(_mintedTokensPerWhitelistedAddress[msg.sender] + amount <= MAX_MINT_PER_WHITELISTED_ADDRESS, "Exceeds MAX_MINT_PER_ADDRESS");

_mintedTokensPerWhitelistedAddress[msg.sender] += amount;

for (uint256 i = 0; i < amount; i++) {
uint256 tokenId = ++tokenSupply;
_mint(to, bytes32(tokenId), allowNonLSP1Recipient, "");
}
}

So what have we done here? We’ve essentially copied the existing mint function, giving it a different price along with a specific time frame for minting, and set the maximum amount of minted tokens per whitelisted address to 3 (using a new mapping to track this amount).

This privateMint takes a merkleProofs parameter in order to verify if the sender is indeed part of the merkle tree (using an OpenZeppelin contract).

Note that we’ve also added a line in publicMint to verify if the private mint has ended.

It’s the end, let’s deploy!

Since I went ahead and created the script and configuration to deploy your contract, you just need to do two things:

  • Go to the scripts/deploy.js file, and set the contract owner address (permission to add/edit data in the data key/value store) there:
const owner = 'YOUR OWNER ADDRESS';
  • And create a .env file with a deployer address private key:
PRIVATEKEY=<YOUR_PRIVATE_KEY>

Done! You’re now ready to deploy:

npx hardhat run scripts/deploy.js --network testnet

It’s now deployed, maybe you want to verify it on the LUKSO blockchain explorer:

npx hardhat verify <deployed-address> <owner-address> --network testnet

You should now have your NFT collection deployed and verified on the network, just like mine on the testnet: LuksoCloneX (0xf5e9C557B5743D4f31697dD64B584A5a7b2e20A8) — TESTNET Explorer

Feel free to mint an NFT from my LuksoCloneX collection!

I disabled the time limits so anyone can mint (to the limit of 100 NFTs).

You can check this link and search for your address. If it’s there, just copy the proofs array, with which you’ll be able to use the privateMint function:

IPFS merkle proofs

You can now go and verify the existence of your token either on https://staging.lookso.io (at the time of this article, LOOKSO is still running on the legacy version, new version to come soon!), or on our graphQL API: https://indexing.testnet.dropps.io/graphql.

@samuel-v#5870 | Lookso
https://indexing.testnet.dropps.io/graphql

Going further

  • You can add royalties to your NFTs using the LSP18 standard (still in discussion at the time of this article)
  • You can also freeze NFTs metadata in the ERC725Y data key/value store, so the owner does not have the permission to change metadata anymore. The only way would be to revoke ownership over the contract, but we might soon see the creation of an LSP to freeze keys in the store

Conclusion

I hope you’ve found this tutorial helpful, as the idea was to provide you with better tools to help you create and innovate within your own projects.

Connect with me on Twitter and Linkedin to keep up with my latest updates, and keep an eye out for more tutorials and walkthroughs like this one!

If you’d like to dive deeper into this work, you can access the complete code of this tutorial on GitHub. Don’t hesitate to use the helper scripts available here to assist you in your own NFT development journey.

I also invite you to explore the LUKSO Improvement Proposals and LUKSO smart contracts for more advanced practices and technical insights:

You can also check out what we’re building at DROPPS!

Remember, the blockchain space is a rapidly evolving one. Always be learning, innovating, and building. Happy coding!

--

--

Samuel VIDEAU
DROPPS

Co-Founder & CTO at DROPPS | ex Software Engineer at LUKSO