Build a Digital Collectibles Portal Using Flow and Cadence (Part 1)

Build a Digital Collectibles Portal Using Flow and Cadence (Part 1)

Understand more about the Flow blockchain and Cadence smart contract language by building a new website focused on collecting digital collectibles.

In this tutorial, we’ll learn how to make a website for collecting digital collectibles (or NFTs) on the blockchain Flow. We'll use the smart contract language Cadence along with React to make it all happen. We'll also learn about Flow, its advantages, and the fun tools we can use.

By the end of this article, you’ll have the tools and knowledge you need to create your own apps.

Let’s dive right in!

Final Output

What are we building?

We're building an application for digital collectibles, and each collectible using a Non-Fungible Token (NFT). To make all this work, we will use Flow's NonFungibleToken Standard, which is a set of rules that helps us manage these special digital items. It's similar to ERC-721, which is used on a different platform called Ethereum.

However, since we are using the Cadence programming language, there are some small differences to be aware of. Our app will allow you to collect NFTs, and each item will be unique from the others.

Prerequisites

Before you begin, be sure to install the Flow CLI on your system. If you haven't done so, follow these installation instructions.

Setting Up

If you're ready to kickstart your project, the first thing you need to do is type in the command "flow setup."

This command does some magic behind the scenes to set up the foundation of your project. It creates a folder system and sets up a file called "flow.json" to configure your project, making sure everything is organized and ready to go!

The project will contain the following folders and files:

  • /contracts: Contains all Cadence contracts.

  • /scripts: Holds all Cadence scripts.

  • /transactions: Stores all Cadence transactions.

  • /tests: Contains all Cadence tests.

  • flow.json: A configuration file for your project, automatically maintained.

Follow the steps below to use Flow NFT Standard.

Step 1: Make a new folder.

First, go to the "flow-collectibles-portal" folder and find the "Cadence" folder. Inside it, create a new folder called "interfaces."

Step 2: Create a file.

Inside the "interfaces" folder, make a new file and name it "NonFungibleToken.cdc."

Step 3: Copy and paste.

Now, open the link named NonFungibleToken which contains the NFT standard. Copy all the content from that file and paste it into the new file you just created ("NonFungibleToken.cdc"). That's it! You've successfully set up the standards for your project.

Now, let’s write some code!

But before we dive into coding, it's important to establish a mental model of how our code will be structured. As developers, it's crucial to have a clear idea.

At the top level, our codebase consists of three main components:

  1. NFT: Each collectible is represented as an NFT.

  2. Collection: A collection refers to a group of NFTs owned by a specific user.

  3. Global Functions and Variables: These are functions and variables defined at the global level for the smart contract and are not associated with any particular resource.

Collectibles Smart Contract

Creating the Collectibles Smart Contract

Create a new file named Collectibles.cdc inside flow-collectibles-portal/cadence/contracts. This is where we will write the code for our NFT Collection.

Contract Structure

import NonFungibleToken from "./interfaces/NonFungibleToken.cdc"
pub contract Collectibles: NonFungibleToken{

  pub var totalSupply: UInt64
  // other code will come here

  init(){
      self.totalSupply = 0
  }

}

Let's break down the code line by line:

  1. First, we'll need to include something called "NonFungibleToken" from our interface folder. This will help us with our contract.

  2. Now, let's write the contract itself. We use the word "contract" followed by the name of the contract. (For this example, let’s call it "Collectibles".) We’ll write all the code inside this contract.

  3. Next, we want to make sure our contract follows certain rules. To do that, we use a special syntax “NonFungibleToken", which means our contract will follow the NonFungibleToken standard.

  4. Then, we’ll create a global variable called "totalSupply." This variable will keep track of how many Collectibles we have. We use the data type "UInt64" for this, which simply means we can only have positive numbers in this variable. No negative numbers allowed!

  5. Now, let's give "totalSupply" an initial value of 0, which means we don't have any Collectibles yet. We'll do this inside a function called "init()".

  6. That's it! We set up the foundation for our Collectibles contract. Now we can start adding more features and functionalities to make it even more exciting.

Before moving forward, please check out the code snippet to understand how we define variables in cadence:

NFT Structure

Now, we'll create a simple NFT resource that holds all the data related to each NFT. We'll define the NFT resource with the pub resource keywords.

Add the following code to your smart contract:

import NonFungibleToken from "./interfaces/NonFungibleToken.cdc"

pub contract Collectibles: NonFungibleToken{

  pub var totalSupply: UInt64

  pub resource NFT: NonFungibleToken.INFT{
        pub let id: UInt64
        pub var name: String
        pub var image: String

        init(_id:UInt64, _name:String, _image:String){
            self.id = _id
            self.name = _name
            self.image = _image
        }
    }

  init(){
      self.totalSUpply = 0
  }
}

As you have seen before, the contract implements the NonFungibleToken standard interface, represented by pub contract Collectibles: NonFungibleToken. Similarly, resources can also implement various resource interfaces.

The NFT resource must also implement the NonFungibleToken.INFT interface, which is a super simple interface that just mandates the existence of a public property called id within the resource.

This is a good opportunity to explain some of the variables we will be using in the NFT resource:

  • id: The Token ID of the NFT

  • name: The name of the owner who will mint this NFT.

  • image: The image of the NFT.

After defining the variable, make sure you initialize its value in the init() function.

Let’s move forward and create another resource called Collection Resource.

Collection Structure

Imagine a Collection as a special folder on your computer that can hold unique digital items called NFTs. Every person who uses this system has their own Collection, just like how everyone has their own folders on their computer.

To better understand, think of it like this: Your computer has a main folder, let's call it "My Account," and inside that, you have a special folder called "My Collection." Inside this "Collection" folder, you can keep different digital items, such as pictures, videos, or music files. Similarly, in this system, when you buy or create NFTs, they get stored in your personal Collection.

For our Collectibles contract, each person who buys NFTs gets their own "Collection" folder, and they can fill it with as many NFTs as they like. It's like having a personal space to store and organize your unique digital treasures!

import NonFungibleToken from "./interfaces/NonFungibleToken.cdc"

pub contract Collectibles: NonFungibleToken{

  pub var totalSupply: UInt64

  pub resource NFT: NonFungibleToken.INFT{
        pub let id: UInt64
        pub var name: String
        pub var image: String

        init(_id:UInt64, _name:String, _image:String){
            self.id = _id
            self.name = _name
            self.image = _image
        }
    }

  // Collection Resource
  pub resource Collection{

  }

  init(){
      self.totalSUpply = 0
  }
}

The Collection resource will have a public variable named ownedNFTs to store the NFT resources owned by this Collection. We'll also create a simple initializer for the Collection resource.

pub resource Collection {
        pub var ownedNFTs: @{UInt64: NonFungibleToken.NFT}
        init(){
            self.ownedNFTs <- {}
        }
}

Resource Interfaces

A resource interface in Flow is similar to interfaces in other programming languages. It sits on top of a resource and ensures that the resource that implements it has the stuff inside of the interface. It can also be used to restrict access to the whole resource and be more restrictive in terms of access modifiers than the resource itself.

In the NonFungibleToken standard, there are several resource interfaces like INFT, Provider, Receiver, and CollectionPublic. Each of these interfaces has specific functions and fields that need to be implemented by the resource that uses them.

In this contract, we will use these three interfaces coming from NonFungibleToken: Provider, Receiver, and CollectionPublic. These interfaces define functions like deposit, withdraw, borrowNFT, and getIDs. We will explain each of these in greater detail as we go.

pub resource interface CollectionPublic{
        pub fun deposit(token: @NonFungibleToken.NFT)
        pub fun getIDs(): [UInt64]
        pub fun borrowNFT(id: UInt64): &NonFungibleToken.NFT
    }

    pub resource Collection: CollectionPublic, NonFungibleToken.Provider, NonFungibleToken.Receiver, NonFungibleToken.CollectionPublic{
        pub var ownedNFTs: @{UInt64: NonFungibleToken.NFT}

        init(){
            self.ownedNFTs <- {}
        }

}

Now, let's create the withdraw() function required by the interface.

pub fun withdraw(withdrawID: UInt64): @NonFungibleToken.NFT {
            let token <- self.ownedNFTs.remove(key: withdrawID) ?? panic("missing NFT")
            emit Withdraw(id: token.id, from: self.owner?.address)
            return <- token
 }

This function first tries to move the NFT resource out of the dictionary. If it fails to remove it (the given withdrawID was not found, for example), it panics and throws an error. If it does find it, it emits a withdraw event and returns the resource to the caller. The caller can then use this resource and save it within their account storage.

Now it’s time for the deposit() function required by NonFungibleToken.Receiver.

pub fun deposit(token: @NonFungibleToken.NFT) {
       let id = token.id
       let oldToken <- self.ownedNFTs[id] <-token
       destroy oldToken
       emit Deposit(id: id, to: self.owner?.address)
}

Now let’s focus on the two functions required by NonFungibleToken.CollectionPublic: borrowNFT() and getID().

pub fun borrowNFT(id: UInt64): &NonFungibleToken.NFT {
    if self.ownedNFTs[id] != nil {
        return (&self.ownedNFTs[id] as &NonFungibleToken.NFT?)!
    }
    panic("NFT not found in collection.")
}

pub fun getIDs(): [UInt64]{
    return self.ownedNFTs.keys
}

There is one last thing we need to do for the Collection Resource: specify a destructor.

Adding a Destructor

Since the Collection resource contains other resources (NFT resources), we need to specify a destructor. A destructor runs when the object is destroyed. This ensures that resources are not left "homeless" when their parent resource is destroyed. We don't need a destructor for the NFT resource as it contains no other resources.

destroy (){
            destroy self.ownedNFTs
}

Check the complete collection resource source code:

pub resource interface CollectionPublic{
        pub fun deposit(token: @NonFungibleToken.NFT)
        pub fun getIDs(): [UInt64]
        pub fun borrowNFT(id: UInt64): &NonFungibleToken.NFT
}

pub resource Collection: CollectionPublic, NonFungibleToken.Provider,  
    NonFungibleToken.Receiver, NonFungibleToken.CollectionPublic{

    pub var ownedNFTs: @{UInt64: NonFungibleToken.NFT}

    init(){
        self.ownedNFTs <- {}
    }

    destroy (){
        destroy self.ownedNFTs
    }

    pub fun withdraw(withdrawID: UInt64): @NonFungibleToken.NFT {
       let token <- self.ownedNFTs.remove(key: withdrawID) ?? panic("missing NFT")
       emit Withdraw(id: token.id, from: self.owner?.address)
       return <- token
    }

    pub fun deposit(token: @NonFungibleToken.NFT) {
        let id = token.id
        let oldToken <- self.ownedNFTs[id] <-token
        destroy oldToken
        emit Deposit(id: id, to: self.owner?.address)
    }

    pub fun borrowNFT(id: UInt64): &NonFungibleToken.NFT {
        if self.ownedNFTs[id] != nil {
            return (&self.ownedNFTs[id] as &NonFungibleToken.NFT?)!
        }
        panic("NFT not found in collection.")
    }

    pub fun getIDs(): [UInt64]{
        return self.ownedNFTs.keys
    }
}

Now we have finished all the resources!

Global Function

Now, let's talk about some global functions you can use:

  1. createEmptyCollection: This function allows you to create an empty Collection in your account storage.

  2. checkCollection: This handy function helps you discover whether or not your account already has a collection.

  3. mintNFT: This function is super cool because it allows anyone to create an NFT.

pub fun createEmptyCollection(): @Collection{
        return <- create Collection()
}

pub fun checkCollection(_addr: Address): Bool{
    return getAccount(_addr)
    .capabilities.get<&{Collectibles.CollectionPublic}>
    (Collectibles.CollectionPublicPath)!
    .check()
}

pub fun mintNFT(name:String, image:String): @NFT{
    Collectibles.totalSupply = Collectibles.totalSupply + 1 
    let nftId = Collectibles.totalSupply
    var newNFT <- create NFT(_id:nftId, _name:name, _image:image)
    return <- newNFT
}

Wrapping Up the Smart Contract

Now we’ve finished writing our smart contract. The final code should look like the combined structure NFT resource, and Collection resources, along with the required interfaces and global functions.

Transaction and Script

What is a transaction?

A transaction is a set of instructions that interact with smart contracts on the blockchain and modify its current state. It's like a function call that changes the data on the blockchain. Transactions usually involve some cost, which can vary depending on the blockchain you are on.

On the other hand, we can use a script to view data on the blockchain, but it does not change it. Scripts are free and are used when you want to look at the state of the blockchain without altering it.

Here is how a transaction is structured in Cadence:

  1. Import: The transaction can import any number of types from external accounts using the import syntax. For example, import NonFungibleToken from 0x01.

  2. Body: The body is declared using the transaction keyword and its contents are contained in curly brackets. It first contains local variable declarations that are valid throughout the whole of the transaction.

  1. Phases: There are two optional main phases: preparation and execution. The preparation and execution phases are blocks of code that execute sequentially.

  2. Prepare Phase: This phase is used to access data/information inside the signer's account (allowed by the AuthAccount type).

  3. Execute Phase: This phase is used to execute actions.

Create Collection Transaction

import Collectibles from "../contracts/Collectibles.cdc"
transaction {
    prepare(signer: AuthAccount) {
        if signer.borrow<&Collectibles.Collection>(from: Collectibles.CollectionStoragePath) == nil {
            let collection <- Collectibles.createEmptyCollection()
            signer.save(<-collection, to: Collectibles.CollectionStoragePath)
            let cap = signer.capabilities.storage.issue<&{Collectibles.CollectionPublic}>(Collectibles.CollectionStoragePath)
            signer.capabilities.publish( cap, at: Collectibles.CollectionPublicPath)
        }
    }
}

Let's break down the code line by line:

  1. This transaction interacts with Collectibles smart contract. Then it checks if the sender (signer) has a Collection resource stored in their account by borrowing a reference to the Collection resource from the specified storage path Collectibles.CollectionStoragePath. If the reference is nil, it means the signer does not have a collection yet.

  2. If the signer does not have a collection, then it creates an empty collection by calling the createEmptyCollection() function.

  3. After creating the empty collection, place into the signer's account under the specified storage path Collectibles.CollectionStoragePath.

It establishes a link between the signer's account and the newly created collection using link().

Mint NFT Transaction

import NonFungibleToken from "../contracts/interfaces/NonFungibleToken.cdc"
import Collectibles from "../contracts/Collectibles.cdc"

transaction(name:String, image:String){
    let receiverCollectionRef: &{NonFungibleToken.CollectionPublic}
    prepare(signer:AuthAccount){
        self.receiverCollectionRef = signer.borrow<&Collectibles.Collection>(from: Collectibles.CollectionStoragePath)
      ?? panic("could not borrow Collection reference")
    }
    execute{
        let nft <- Collectibles.mintNFT(name:name, image:image)
        self.receiverCollectionRef.deposit(token: <-nft)
    }
}

Let’s break down the code line by line:

  1. We first import the NonFungibleToken and Collectibles contract.

  2. transaction(name: String, image: String)
    This line defines a new transaction. It takes two arguments, name and image, both of type String. These arguments are used to pass the name and image of the NFT being minted.

  3. let receiverCollectionRef: &{NonFungibleToken.CollectionPublic}
    This line declares a new variable receiverCollectionRef. It is a reference to a public collection of NFTs of type NonFungibleToken.CollectionPublic. This reference will be used to interact with the collection where we will deposit the newly minted NFT.

  4. prepare(signer: AuthAccount)
    (This line starts the prepare block, which is executed before the transaction.) It takes an argument signer of type AuthAccount. AuthAccount represents the account of the transaction’s signer.

  5. Inside the prepare block, it borrows a reference to the Collectibles.Collection from the signer’s storage. It uses the borrow function to access the reference to the collection and store it in the receiverCollectionRef variable. If the reference is not found (if the collection doesn’t exist in the signer’s storage, for example), it will throw the error message “could not borrow Collection reference”.

  6. The execute block contains the main execution logic for the transaction. The code inside this block will be executed after the prepare block has successfully completed.

  7. nft <- Collectibles.mintNFT(_name: name, image: image) Inside the execute block, this line calls the mintNFT function from the Collectibles contract with the provided name and image arguments. This function is expected to create a new NFT with the given name and image. The <- symbol indicates that the NFT is being received as an object that can be moved (a resource).

  8. self.receiverCollectionRef.deposit(token: <-nft)
    This line deposits the newly minted NFT into the specified collection. It uses the deposit function on the receiverCollectionRef to transfer ownership of the NFT from the transaction’s executing account to the collection. The <- symbol here also indicates that the NFT is being moved as a resource during the deposit process.

View NFT Script

import NonFungibleToken from "../contracts/interfaces/NonFungibleToken.cdc"
import Collectibles from "../contracts/Collectibles.cdc"

pub fun main(user: Address, id: UInt64): &NonFungibleToken.NFT? {
  let collectionCap= getAccount(user).capabilities
                      .get<&{Collectibles.CollectionPublic}>(/public/NFTCollection) 
                      ?? panic("This public capability does not exist.")

  let collectionRef = collectionCap.borrow()!

  return collectionRef.borrowNFT(id: id)
}

Let's break down the code line by line:

  1. First we import the NonFungibleToken and Collectibles contract.

  2. pub fun main(acctAddress: Address, id: UInt64): &NonFungibleToken.NFT?
    This line defines the entry point of the script, which is a public function named main. The function takes two parameters:

  • acctAddress: An Address type parameter representing the address of an account on the Flow Blockchain.

  • id: A UInt64 type parameter representing the unique identifier of the NFT within the collection.

  1. Then we use getCapability to fetch the Collectibles.Collection capability for the specified acctAddress. A capability is a reference to a resource that allows access to its functions and data. In this case, it is fetching the capability for the Collectibles.Collection resource type.

  2. Then, we borrow an NFT from the collectionRef using the borrowNFT function. The borrowNFT function takes the id parameter, which is the unique identifier of the NFT within the collection. The borrow function on a capability allows reading the resource data.

  3. Finally, we return the NFT from the function.

Testnet Deployment

Follow the steps to deploy the collectibles contract to the Flow Testnet.

1. Set up a Flow account.

Run the following command in the terminal to generate a Flow account:

flow keys generate

Be sure to write down your public key and private key.

Next, we’ll head over to the Flow Faucet, create a new address based on our keys, and fund our account with some test tokens. Complete the following steps to create your account:

  1. Paste in your public key in the specified input field.

  2. Keep the Signature and Hash Algorithms set to default.

  3. Complete the Captcha.

  4. Click on Create Account.

After setting up an account, we receive a dialogue with our new Flow address containing 1,000 test Flow tokens. Copy the address so we can use it going forward.

2. Configure the project.

Ensure your project is configured correctly by verifying the contract's source code location, account details, and contract name.

{
    "emulators": {
        "default": {
            "port": 3569,
            "serviceAccount": "emulator-account"
        }
    },
    "contracts": {
        "NonFungibleToken": {
            "source": "./cadence/contracts/interfaces/NonFungibleToken.cdc",
            "aliases": {
                "testnet": "0x631e88ae7f1d7c20"
            }
        },
        "Collectibles": "./cadence/contracts/Collectibles.cdc"
    },
    "networks": {
        "testnet": "access.devnet.nodes.onflow.org:9000"
    },
    "accounts": {
        "emulator-account": {
            "address": "0xf8d6e0586b0a20c7",
            "key": "61dace4ff7f2fa75d2ec4a009f9b19d976d3420839e11a3440c8e60391699a73"
        },
        "contract": {
            "address": "0x490b5c865c43d0fd",
            "key": {
                "type": "hex",
                "index": 0,
                "signatureAlgorithm": "ECDSA_P256",
                "hashAlgorithm": "SHA3_256",
                "privateKey": "priavte_key"
            }
        }
    },
    "deployments": {
        "testnet": {
            "contract": [
                "Collectibles"
            ]
        }
    }
}

3. Copy and paste.

Paste your generated private key and account address inside accounts -> contract section.

4. Execute.

Go to the terminal and run the following code:

flow project deploy --network testnet

5. Wait for confirmation.

After submitting the transaction, you'll receive a transaction ID. Wait for the transaction to be confirmed on the testnet, indicating that the smart contract has been successfully deployed.

Check your deployed contract here: Flow Source.

Final Thoughts and Congratulations!

Congratulations! You have now built a collectibles portal on the Flow blockchain and deployed it to the testnet. What’s next? Now you can work on building the frontend which we will cover in part 2 of this series.

Have a really great day!