Writing CW-721 NFT Contract

Introduction

NFT(Non fungible tokens) is a hot buzzword in recent years on the topic of digital assets . In this tutorial we are gonna build our own NFT and deploy it to the Oraichain network

What is an NFT

NFTs can be understood as the unique tokens that cannot be replicated unlike tokens (which can be replicated and have constant value) , NFTs allows us to tokenize real-world items such as real-assets , artwork and even AI models (which is possible on Oraichain). On Oraichain we use CW-721 standard for NFTs which is analogous to ERC-721.

Overview

Before going into the steps for building our first NFT on Oraichain , let’s understand the contracts we are gonna write and how our project structure will be like demonstrated in the below flowchart .

We will store and instantiate all of our NFT data in the CW-721 base contract , then we can call the contract address to create NFT , But if we want to create multiple NFTs the heavy-lifting is done by the CW-721 Factory contract so that we are only required to deploy the contract only one time .

CW-20 contract which we have learnt about in this tutorial will create a fixed-price-nft contract. It allows users to pay fee in CW-20 tokens and get one new NFT in return .

Create cw721-nft project

For creating your boilerplate project run the command

cargo generate --git <https://github.com/CosmWasm/cw-template.git> --branch 1.0-minimal --name cw721-nft

Now , source code for this contract can be found in in src folder . We will start in the src/state.rs file . Upon opening it you should see code like the following :

use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use cosmwasm_std::Addr;
use cw_storage_plus::Item;

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct State {
    pub count: i32,
    pub owner: Addr,
}

pub const STATE: Item<State> = Item::new("state");

Let’s walk through this

The first 5 lines of code are importing other structs, interfaces and functions from other packages. To give a quick overview without getting bogged down too much:

  • JsonSchema allows structs to be serialized and deserialized to and from JSON.

  • Deserialize and Serialize provide the serialization described above.

  • Addr is a Cosmos address, under the hood it is simply a string.

  • Item is a helper provided by storage plus. It effectively means we can store an item in storage. In this case, the STATE variable is an Item that stores a singular State struct.

We want to make some changes to this, let's rename it accordingly. I prefer our global state to be called Config as it is the configuration of our contract. Let's do this now and remove the count and owner variable from the struct and rename it to Config. Let's also rename STATE to CONFIG.

It should look like this:


use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use cosmwasm_std::Addr;
use cw_storage_plus::Item;

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct Config {

}

pub const CONFIG: Item<Config> = Item::new("config");

Now let's think about what global configs we want, we probably want an NFT Minter Contract that can mint some amazing NFTs for buyer with fixed price fee. Let's add some necessary fields representing the configuration parameters of our NFT minter contract.

// Previous code omitted
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct Config {
    pub owner: Addr,
    pub cw20_address: Addr,
    pub cw721_address: Option<Addr>,
    pub max_tokens: u32,
    pub unit_price: Uint128,
    pub name: String,
    pub symbol: String,
    pub token_uri: String,
    pub extension: Extension,
    pub unused_token_id: u32,
}
// Following code omitted

Now I'll explain to you about the above data fields.

  • The owner field specifies which account is the owner of your contract.

  • The cw20_address field specifies the address of which cw20 token contract used as a payment method. We will use the contract deployed in chapter 3.

  • The cw721_address field specifies the address of the cw721 token contract we will use. But the cw721 is created dynamically during contract instantiation, so there's no need to instantiate a cw721 token contract separately.

  • The max_tokens field specifies the maximum mint token amount of this contract.

  • The unit_price field specifies the unit price for each NFT.

  • name, symbol, token_uri, extension fields specifies the NFT token info and metadata. Here, the extension is of type cw721_base::Extension. So you need to add a line like the following in the declaration of the library used by Rust.

use cw721_base::Extension;

Also need to declare the use of cw721-base as a dependency in Cargo.toml. Open Cargo.toml and add this to the dependencies:

# ...

[dependencies]
cw721-base = { version = "0.15.0", features = ["library"] }
cw-utils = "0.12.1"

# ...

<aside> 🦀 The unused_token_id field is used to store the total number of NFT tokens have been minted. The reason it is named 'unused_token_id' is because, when executing the mint action, the NFT token will be assigned a token_id equal to the value of the current 'unused_token_id'.

</aside>

Ok, here we have done all the necessary work. In the next steps , we will move on to working in the src/contract.rs.

Instantiating the Contract

When contracts are to be deployed on the chain they must be instantiated . For that we use a special message that is an InstantiateMsg located under src/lib.rs .

The InstantiateMsg

When instantiating our contract , we need to specify most of the fields in the config struct defined in the state.rs file

Go to src/msg.rs and modify InstantiateMsg looks like this :

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub struct InstantiateMsg {
    pub owner: Addr,
    pub max_tokens: u32,
    pub unit_price: Uint128,
    pub name: String,
    pub symbol: String,
    pub token_code_id: u64,
    pub cw20_address: Addr,
    pub token_uri: String,
    pub extension: Extension,
}

We have discussed each of these fields in the previous section . There is only one field to specify , it is token_code_id . We already know that , to be able to deploy a contract we need to first store the code on the chain ,and that code will be given a code_id. token_code_id in here will be the code_id of the cw721_base contract, which we will store on the chain to use for this contract. In above msg, we use serde attribute. It's a framework for serializing and deserializing Rust data structures efficiently and generically. We use it to rename all the fields (if this is a struct) or variants (if this is an enum) according to the given case convention. When you generate schema of project, all parameters will be write in snake_case type.

Instantiation

The instantiation code is implemented in src/contract.rs

const CONTRACT_NAME: &str = "crates.io:orai-nft";
const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION");

const INSTANTIATE_TOKEN_REPLY_ID: u64 = 1;

#[cfg_attr(not(feature = "library"), entry_point)]
pub fn instantiate(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    msg: InstantiateMsg,
) -> Result<Response, ContractError> {
    set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?;

    if msg.unit_price == Uint128::new(0) {
        return Err(ContractError::InvalidUnitPrice {});
    }

    if msg.max_tokens == 0 {
        return Err(ContractError::InvalidMaxTokens {});
    }

    let config = Config {
        cw721_address: None,
        cw20_address: msg.cw20_address,
        unit_price: msg.unit_price,
        max_tokens: msg.max_tokens,
        owner: info.sender,
        name: msg.name.clone(),
        symbol: msg.symbol.clone(),
        token_uri: msg.token_uri.clone(),
        extension: msg.extension.clone(),
        unused_token_id: 0,
    };

    CONFIG.save(deps.storage, &config)?;

    let sub_msg: Vec<SubMsg> = vec![SubMsg {
        msg: WasmMsg::Instantiate {
            code_id: msg.token_code_id,
            msg: to_binary(&Cw721InstantiateMsg {
                name: msg.name.clone(),
                symbol: msg.symbol,
                minter: env.contract.address.to_string(),
            })?,
            funds: vec![],
            admin: None,
            label: String::from("Instantiate fixed price NFT contract"),
        }
        .into(),
        id: INSTANTIATE_TOKEN_REPLY_ID,
        gas_limit: None,
        reply_on: ReplyOn::Success,
    }];

    Ok(Response::new().add_submessages(sub_msg))
}

// Reply callback triggered from cw721 contract instantiation
#[cfg_attr(not(feature = "library"), entry_point)]
pub fn reply(deps: DepsMut, _env: Env, msg: Reply) -> Result<Response, ContractError> {
    let mut config: Config = CONFIG.load(deps.storage)?;

    if config.cw721_address != None {
        return Err(ContractError::Cw721AlreadyLinked {});
    }

    if msg.id != INSTANTIATE_TOKEN_REPLY_ID {
        return Err(ContractError::InvalidTokenReplyId {});
    }

    let reply = parse_reply_instantiate_data(msg).unwrap();
    config.cw721_address = Addr::unchecked(reply.contract_address).into();
    CONFIG.save(deps.storage, &config)?;

    Ok(Response::new())
}

Alrighty ! that’s a lot of code , let’s walk through this code step by step :

You can see the instantiate has 4 arguments:

  • deps - The dependencies, this contains your contract storage, the ability to query other contracts and balances, and some API functionality.

  • env - The environment, contains contract information such as its address, block information such as current height and time, as well as some optional transaction info.

  • info - Message metadata, contains the sender of the message (Addr) and the funds sent with it a Vec<Coin>.

  • msg - The InstantiateMsg you define in src/msg.rs.

In the first line, set_contract_version function uses a standard called cw2, it allows contracts to store version and name as you look at.

set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?;

The next two if statements is the logic to validate the input of two InstantiateMsg fields: unit_price and max_tokens.


if msg.unit_price == Uint128::new(0) {
    return Err(ContractError::InvalidUnitPrice {});
}

if msg.max_tokens == 0 {
    return Err(ContractError::InvalidMaxTokens {});
}

If unit_price (the price to mint NFT) or max_tokens (the maximum number of NFTs that can be minted) equal to zero, our instantiate function will return the following 2 errors respectively: InvalidUnitPrice and InvalidMaxTokens. They are defined in src/error.rs:


pub enum ContractError {

    // Previous code omitted

    #[error("InvalidUnitPrice")]
    InvalidUnitPrice {},

    #[error("InvalidMaxTokens")]
    InvalidMaxTokens {},

    // Following code omitted
}

The above two if statements you will often see in other functions later. Because checking the input condition of the data is very important.

Ok, the next line creates a Config struct which was defined in the State chapter. And you can see, we have to use clone() in some fields to avoid moving values.

The line following that stores it in our CONFIG storage. (Ensure you have imported CONFIG from state.rs). It does this by calling it with deps.storage which is our contracts storage and giving it the address of our newly created config variable. It does this by preceding it with the & character.

Below the save CONFIG statement, we will define a sub_msg variable. Because there are new concepts here, I separate them into a section.

Submessage

let sub_msg: Vec<SubMsg> = vec![SubMsg {
        msg: WasmMsg::Instantiate {
            code_id: msg.token_code_id,
            msg: to_binary(&Cw721InstantiateMsg {
                name: msg.name.clone(),
                symbol: msg.symbol,
                minter: env.contract.address.to_string(),
            })?,
            funds: vec![],
            admin: None,
            label: String::from("Instantiate fixed price NFT contract"),
        }
        .into(),
        id: INSTANTIATE_TOKEN_REPLY_ID,
        gas_limit: None,
        reply_on: ReplyOn::Success,
    }];

As mentioned above, we use token_code_id field to call the initialization of a contract from previously uploaded Wasm code with the corresponding code_id. This can be done by sending a message of WasmMsg::Instantiate type. But we wanna get the result of the message sent from our smart contract (address of new contract), so we need to dispatch a sub message.

The variable sub_msg here is defined as a vector containing SubMsg (sub message). Of course it's possible to define it as just a single SubMsg, but maybe in the future you want to send another SubMsg, so it's better to define it as a vector of SubMsg. So what's inside a SubMsg?

  • msg: is message to be sent. In our case, it is a WasmMsg::Instantiate

  • id: is an arbitrary reply_ID chosen by the contract that will be used to handle the reply. This is typically used to match Replys in the reply entry point to the submessage. Here, we use INSTANTIATE_TOKEN_REPLY_ID is the SubMsg id.

  • gas_limit: is gas limit for the submessage.

  • reply_on: is a flag to determine when the reply should be sent. Submessages offer different options for the other contract to provide a reply. There are four reply options (Always, Error, Success, Never) you can choose and they are defined in ReplyOn enum. Here, we use ReplyOn::Success to only callback if SubMsg was successful, no callback on error case.

Next, we will learn about WasmMsg::Instantiate and its fields. WasmMsg::Instantiate is used to instantiate a new contracts from previously uploaded Wasm code.

  • sender is the actor that signed the messages, and is automatically filled with the current contract’s address.

  • code_id is the reference to the stored WASM code.

  • funds is coins amount that are transferred to the contract on instantiation.

  • admin is an optional address that can execute migrations.

  • label is optional metadata to be stored with a contract instance.

Finally, the last line of the instantiate function:

Ok(Response::new().add_submessages(sub_msg))

🦀 The final line is our return line indicated not include ;. This returns a success using the Ok and Result structure of Response type. Response is a response of a contract entry point, such as instantiate, execute, reply or migrate. Within the Ok structure, we create a response using various builder methods. We use add_submessages to add bulks explicit SubMsg structs to the list of messages to process.

Handling a reply

In order to handle the reply from the other contract, the calling contract must implement a new entry point. Here, our contract want to know cw721 contract (this contract link to our contract) address after cw721 contract instantiation.

#[cfg_attr(not(feature = "library"), entry_point)]
pub fn reply(deps: DepsMut, _env: Env, msg: Reply) -> Result<Response, ContractError> {
    let mut config: Config = CONFIG.load(deps.storage)?;

    if config.cw721_address != None {
        return Err(ContractError::Cw721AlreadyLinked {});
    }

    if msg.id != INSTANTIATE_TOKEN_REPLY_ID {
        return Err(ContractError::InvalidTokenReplyId {});
    }

    let reply = parse_reply_instantiate_data(msg).unwrap();
    config.cw721_address = Addr::unchecked(reply.contract_address).into();
    CONFIG.save(deps.storage, &config)?;

    Ok(Response::new())
}

In the first line, by using load(), we get the CONFIG Item data previously stored in the store and assign it to a config variable. Note that the mut keyword is used here because in this function we will modify the data of config.

The next two if statements is the logic to validate two fields: config.cw721_address and msg.id. First, if config.cw721_address already exists, the Cw721AlreadyLinked error is returned. Second, if msg.id is not equal to the reply_id of SubMsg, the InvalidTokenReplyId error will be returned. Let define these 2 errors in src/error.rs

pub enum ContractError {

    // Previous code omitted

    #[error("InvalidTokenReplyId")]
    InvalidTokenReplyId {},

    #[error("Cw721AlreadyLinked")]
    Cw721AlreadyLinked {},

    // Following code omitted
}

About the last four lines of reply function.

  • In the first line, we use parse_reply_instantiate_data(), function of registry cw_utils with msg input to create a reply variable with it's type is a MsgInstantiateContractResponse struct. This struct has contract_address field that our contract need.

  • In the second line, we will assign the value reply.contract_address to the cw721_address field of the config. There is data modification here, so config is declared with mut keyword.

  • In the third line, we store config in our CONFIG storage to save changes.

  • And the last line, similar to instantiate function, is our return line using the Ok structure of Response type.

Well, we have implemented the first instantiate entry point of our contract! Along with that, is reply entry point to reply callback triggered from cw721 contract instantiation. In the next section, we will implement execute entry_point for it!

Execute Msg

Now we will write the execution logic of our contract .

Define ExecuteMsg

Head back to msg.rs . Our ExecuteMsg looks like this

pub enum ExecuteMsg {
    Receive(Cw20ReceiveMsg),
}

Cw20ReceiveMsg should be de/serialized under Receive() variant in a ExecuteMsg. Receive must be implemented by any contract that wishes to manage CW20 tokens.

pub struct Cw20ReceiveMsg {
    pub sender: String,
    pub amount: Uint128,
    pub msg: Binary,
}

About Cw20ReceiveMsg, it is a struct that has three

fields: sender, amount and msg. The sender is the original account requesting to move the tokens and msg is a Binary data that can be decoded into a contract-specific message. amount is the amount of Cw20 tokens sent from sender to the contract.

Write execution logic

#[cfg_attr(not(feature = "library"), entry_point)]
pub fn execute(
    deps: DepsMut,
    _env: Env,
    info: MessageInfo,
    msg: ExecuteMsg,
) -> Result<Response, ContractError> {
    match msg {
        ExecuteMsg::Receive(Cw20ReceiveMsg {
            sender,
            amount,
            msg,
        }) => execute_receive(deps, info, sender, amount, msg),
    }
}

pub fn execute_receive(
    deps: DepsMut,
    info: MessageInfo,
    sender: String,
    amount: Uint128,
    _msg: Binary,
) -> Result<Response, ContractError> {
    let mut config = CONFIG.load(deps.storage)?;
    if config.cw20_address != info.sender {
        return Err(ContractError::UnauthorizedTokenContract {});
    }

    if config.cw721_address == None {
        return Err(ContractError::Uninitialized {});
    }

    if config.unused_token_id >= config.max_tokens {
        return Err(ContractError::SoldOut {});
    }

    if amount != config.unit_price {
        return Err(ContractError::WrongPaymentAmount {});
    }

    let mint_msg = Cw721ExecuteMsg::<Extension, Empty>::Mint(MintMsg::<Extension> {
        token_id: config.unused_token_id.to_string(),
        owner: sender,
        token_uri: config.token_uri.clone().into(),
        extension: config.extension.clone(),
    });

    match config.cw721_address.clone() {
        Some(cw721) => {
            let callback =
                Cw721Contract::<Empty, Empty>(cw721, PhantomData, PhantomData).call(mint_msg)?;
            config.unused_token_id += 1;
            CONFIG.save(deps.storage, &config)?;

            Ok(Response::new().add_message(callback))
        }
        None => Err(ContractError::Cw721NotLinked {}),
    }
}

In execute function, we use Rust's enum pattern matching to match the msg variable for all its different types. Here, our ExecuteMsg has only one variant is a tuple struct: Receive(Cw20ReceiveMsg{sender,amount,msg}). After determining type of the msg message variable is Cw20ReceiveMsg, we will use => to call the execute_receive function with its input is deps, info, sender, amount, msg.

Now, let's define the logic of execute_receive function.

In the first line, by using load(), we get the CONFIG Item data previously stored in the store and assign it to a config variable. Note that the mut keyword is used here because in this function we will modify the data of config.

The next four if statements is the logic to validate four fields: config.cw20_address, config.cw721_address, config.unused_token_id, and amount.

  • Firstly, if config.cw20_address is not equal to info.sender, the UnauthorizedTokenContract error is returned because our contract only accepts Cw20 tokens with token contract address specified in config.

  • Secondy, if config.cw721_address is None, the Uninitialized error will be returned.

  • Thirdly, if config.unused_token_id is greater than or equal to config.max_tokens, the SoldOut error will be returned. Because it means that the number of NFTs minted has reached the maximum, and the contract cannot receive Cw20 tokens to mint any more NFTs.

  • Fourth, if amount is not equal to config.unit_price, the WrongPaymentAmount will be returned because the fixed minting fee of one NFT must be equal to unit_price.

    Let define these 4 errors in src/error.rs.

pub enum ContractError {

    // Previous code omitted

    #[error("SoldOut")]
    SoldOut {},

    #[error("UnauthorizedTokenContract")]
    UnauthorizedTokenContract {},

    #[error("Uninitialized")]
    Uninitialized {},

    #[error("WrongPaymentAmount")]
    WrongPaymentAmount {},

    #[error("Cw721NotLinked")]
    Cw721NotLinked {},
    // Following code omitted
}

In the next line, we define mint_msg message variable.

let mint_msg = Cw721ExecuteMsg::<Extension, Empty>::Mint(MintMsg::<Extension> {
        token_id: config.unused_token_id.to_string(),
        owner: sender,
        token_uri: config.token_uri.clone().into(),
        extension: config.extension.clone(),
});

mint_msg is used to mint NFT so it will be a message of type Cw721ExecuteMsg::Mint. Cw721ExecuteMsg is defined in cw721_base, it is a tuple struct with the inner struct is MintMsg (also defined in cw721_base). We define its fields (token_id, owner, token_uri, extension) like in the code above.

And finally, we again use the Rust enum pattern to define the callback message (callback message is the message sent by the contract after receiving an activation message).

match config.cw721_address.clone() {
        Some(cw721) => {
            let callback =
                Cw721Contract::<Empty, Empty>(cw721, PhantomData, PhantomData).call(mint_msg)?;
            config.unused_token_id += 1;
            CONFIG.save(deps.storage, &config)?;

            Ok(Response::new().add_message(callback))
        }
        None => Err(ContractError::Cw721NotLinked {}),
}

callback in here is a CosmosMsg message to trigger the mint function on the Cw721 contract that is link to our contract after instantiation. If the NFT is minted, its total number of NFTs will be increased by one, it is applied by updating config.unused_token_id and saving the config to storage.

If cw721_address is already specified in config (it means there is a cw721 contract linked to our contract), then we will use for execute_receive function a return line with Ok structure and add_message(callback). Otherwise, return Cw721NotLinked error.

Ok nice, in this chapter, we have defined the execute entry point for our contract. In the next chapter, we will define the last required entry point of a CosmWasm contract - query.

Query

So you're asking, How do we change the state of our contract but what if we want to read some details? We don't need to pay transaction fees on that, do we? No, my friend, this is what querying is for!

Querying is the other half of the coin to messages. You can think of queries as a database read, or a way of querying state.

So as we did with all other sections let's head to where it starts src/msg.rs

pub enum QueryMsg {
    GetConfig {},
}

pub struct ConfigResponse {
    pub owner: Addr,
    pub cw20_address: Addr,
    pub cw721_address: Option<Addr>,
    pub max_tokens: u32,
    pub unit_price: Uint128,
    pub name: String,
    pub symbol: String,
    pub token_uri: String,
    pub extension: Extension,
    pub unused_token_id: u32,
}

Our QueryMsg is very simple. Because we only define a single state - the Config struct, so we only need a single QueryMsg to get the Config.

Below QueryMsg, we define the ConfigResponse struct to be used as the return result for the GetConfig {} query. Its fields are the same as the Config struct state.

Write querying logic

#[cfg_attr(not(feature = "library"), entry_point)]
pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult<Binary> {
    match msg {
        QueryMsg::GetConfig {} => to_binary(&query_config(deps)?),
    }
}

fn query_config(deps: Deps) -> StdResult<ConfigResponse> {
    let config = CONFIG.load(deps.storage)?;
    Ok(ConfigResponse {
        owner: config.owner,
        cw20_address: config.cw20_address,
        cw721_address: config.cw721_address,
        max_tokens: config.max_tokens,
        unit_price: config.unit_price,
        name: config.name,
        symbol: config.symbol,
        token_uri: config.token_uri,
        extension: config.extension,
        unused_token_id: config.unused_token_id,
    })
}

You can see in query function, QueryMsg are handled differently than InstantiateMsg or ExecuteMsg. When returning from a query you don't return via a Response. You must define a custom struct which can then be encoded to Binary.

When msg matches with QueryMsg::GetConfig{}, the query thread are passed to the query_config function.

About the query_config function, in the first line, by using load(), we get the CONFIG Item data previously stored in the store and assign it to a config variable.

And at the end, it's a simple return line using the Ok structure, inside it is a ConfigResponse struct containing the return result.

With this we have successfully written our NFT contract on Oraichain , For Deploying your contract you can follow the next tutorial .

Last updated