📜Smart Contract Implementation

This tutorial demonstrates how to modify a smart contract on the Concordium blockchain platform to support multiple metadata for tokens and enable functionalities such as adding, upgrading, etc.

Understanding the Contract Modifications

You'll modify the CIS-2 Multi contract from the Concordium example smart contracts repository in this tutorial. The fully modified contract can be found at the end of this section.

Since this is a mid-level tutorial, it is assumed that you have completed the other token tutorials in the developer portal. Therefore, this tutorial will not explain everything in detail.

Overview of Example Scenario

Before implementing the contract, let's review an example scenario to illustrate its functionalities. Imagine a tiny puppy that grows into a strong dog. Whenever you wish to display your pet, you will see its latest form, with the transformation being triggerable only by the contract owner.

Contract Functionality Requirements

Here's an overview of the desired functionalities for the d-NFT contract

  1. Support for Multiple Metadata: The d-NFT contract should be capable of accommodating multiple metadata for each token.

  2. Ability to Add New Metadata: The contract owner should be able to add new metadata to tokens.

  3. Upgrade Existing Metadata: The contract owner should be able to upgrade the existing metadata associated with tokens.

  4. History of Metadata List: There should be functionality to track and view the history of changes made to the metadata list associated with tokens.

Follow the steps below.

Step 1: Contract State Modification

Let's begin by adjusting the state. This involves modifying the state variables and creating structs for necessary logic, getter, and setter functions. Here's what you'll do:

  • Instead of directly storing a MetadataUrl in tokens to represent the metadata URL for that token, you'll use another struct called TokenMetadataState.

  • This struct contains a counter value and a list of MetadataUrl instances, allowing multiple MetadataUrls to be associated with a token.

  • The counter keeps track of the current index value of the MetadataUrl in the list.

  • Upon upgrading, increment the counter. The latest value will then refer to the token's current MetadataUrl.

Use the following code to modify your contract state:

/// The token state keeps a counter variable (as an index)
/// and a list of MetadataUrls. The counter points to the nth index
/// of the list of MetadataUrls. The idea is to return the value at that index
/// when it's queried by the `TokenMetadata` function.
/// When the owner of the contract upgrades this token, the counter will be
/// incremented by 1 so that the next MetadataUrl from the list becomes active.
#[derive(Serial, Deserial, Clone, SchemaType)]
pub struct TokenMetadataState {
    /// The counter is initially 0. When the owner of the contract
    /// upgrades this token, the counter will be incremented by 1.
    pub token_metadata_current_state_counter: u32,
    /// List of MetadataUrls for different stages of the token
    pub token_metadata_list:                  Vec<MetadataUrl>,
}
impl TokenMetadataState {
    fn add_metadata(&mut self, metadata: MetadataUrl) { self.token_metadata_list.push(metadata); }
}
/// The contract state,
/// Note: The specification does not specify how to structure the contract state
/// and this could be structured in a more space efficient way.
#[derive(Serial, DeserialWithState)]
#[concordium(state_parameter = "S")]
struct State<S> {
    /// The state of addresses.
    state:        StateMap<Address, AddressState<S>, S>,
    /// Token IDs and the MetadataStates which holds the counter and the list of
    /// MetadataURls
    tokens:       StateMap<ContractTokenId, TokenMetadataState, S>,
    /// Map with contract addresses providing implementations of additional
    /// standards.
    implementors: StateMap<StandardIdentifierOwned, Vec<ContractAddress>, S>,
}

Step 2: Modifying the Mint Function

Modify the mint function in the state accordingly to enable the setting of initial values when invoked. Upon initializing the token's state, note the following:

  • token_metadata_current_state_counter has to be 0

  • token_metadata_list will be given by the MintParams.

Use the following code to modify your mint() function:

/// Mints an amount of tokens with a given address as the owner.
    /// Sets the token's metadata list and the current metadata counter to 0.
    fn mint(
        &mut self,
        token_id: &ContractTokenId,
        mint_param: &MintParam,
        owner: &Address,
        state_builder: &mut StateBuilder<S>,
    ) {
        self.tokens.insert(*token_id, TokenMetadataState {
            token_metadata_current_state_counter: 0,
            token_metadata_list:                  mint_param.metadata_url.clone(),
        });
        let mut owner_state =
            self.state.entry(*owner).or_insert_with(|| AddressState::empty(state_builder));
        let mut owner_balance = owner_state.balances.entry(*token_id).or_insert(0.into());
        *owner_balance += mint_param.token_amount;
    }

Step 3: Adding Functionality for Metadata Upgrades

Implement a function to allow the contract owner to upgrade token metadata by incrementing the counter of the MetadataUrl index. Use the following code:

/// Picks the next MetadataUrl from the list when token is upgraded by
    /// the contract owner
    fn upgrade_token(&mut self, token_id: &ContractTokenId) -> ContractResult<()> {
        ensure!(self.contains_token(token_id), ContractError::InvalidTokenId);

        let counter = self.get_token_state_medata_counter(token_id);
        ensure!(
            counter < self.tokens.get(token_id).unwrap().token_metadata_list.len(),
            ContractError::Custom(CustomContractError::UpgradeTokenFailedError)
        );

        self.tokens.get_mut(token_id).unwrap().token_metadata_current_state_counter += 1;
        Ok(())
    }

Step 4: Implementing Functionality to Add Metadata

Create a function to enable the contract owner to add new metadata. Use the following code:

/// Adds a new Metadata URL to the MetdataList at a time
    fn add_metadata(
        &mut self,
        token_id: &ContractTokenId,
        mint_param: &MetadataUrl,
    ) -> ContractResult<()> {
        ensure!(self.contains_token(token_id), ContractError::InvalidTokenId);
        let update = MetadataUrl {
            url:  mint_param.url.clone(),
            hash: mint_param.hash,
        };

        let mut token = self.tokens.get_mut(token_id).unwrap();

        token.add_metadata(update);
        Ok(())
}

Step 5: Implementing Getter Functions

Develop getter functions to retrieve token metadata, counters, and tokens themselves with the following:

fn get_token_state_medata_counter(&self, token_id: &ContractTokenId) -> usize {
    // Getter logic...
}

fn get_metadata(&self, token_id: &ContractTokenId) -> ContractResult<MetadataUrl> {
    // Getter logic...
}

fn get_token(&self, token_id: &ContractTokenId) -> Option<MetadataUrl> {
    // Getter logic...
}

Step 6: Implementing Mint Entry Point

  1. Create a receive entry point for the mint function and include the required parameters. You'll be doing the following:

  • Modify the MintParams struct.

  • Ensure that there is a list of MetadataUrl when minting.

  • Ensure the contract owner can mint a d-NFT with multiple metadata.

Use the following code to implement the mint entry point:

/// MintParam expects the token amount as TokenAmountU64
/// and a vector of MetadataUrl
#[derive(Serial, Deserial, SchemaType)]
pub struct MintParam {
    /// Number of tokens
    pub token_amount: ContractTokenAmount,
    /// MetadataURLs for a token
    pub metadata_url: Vec<MetadataUrl>,
}

/// The parameter for the contract function `mint` which mints a number of
/// token types and/or amounts of tokens to a given address.
#[derive(Serial, Deserial, SchemaType)]
pub struct MintParams {
    /// Owner of the newly minted tokens.
    pub owner:  Address,
    /// A collection of tokens to mint.
    pub tokens: collections::BTreeMap<ContractTokenId, MintParam>,
}
  1. Modify the mint entrypoint for the logged event with the following code:

/// Mint new tokens with a given address as the owner of these tokens.
/// Can only be called by the contract owner.
/// Logs a `Mint` and a `TokenMetadata` event for each token.
///
/// It rejects if:
/// - The sender is not the contract instance owner.
/// - Fails to parse parameter.
/// - Any of the tokens fails to be minted, which could be if:
///     - Fails to log Mint event.
///     - Fails to log TokenMetadata event.
///
/// Note: Can at most mint 32 token types in one call due to the limit on the
/// number of logs a smart contract can produce on each function call.
#[receive(
    contract = "cis2_dynamic_nft",
    name = "mint",
    parameter = "MintParams",
    error = "ContractError",
    enable_logger,
    mutable
)]
fn contract_mint<S: HasStateApi>(
    ctx: &impl HasReceiveContext,
    host: &mut impl HasHost<State<S>, StateApiType = S>,
    logger: &mut impl HasLogger,
) -> ContractResult<()> {
    // Get the contract owner
    let owner = ctx.owner();
    // Get the sender of the transaction
    let sender = ctx.sender();

    ensure!(sender.matches_account(&owner), ContractError::Unauthorized);

    // Parse the parameter.
    let params: MintParams = ctx.parameter_cursor().get()?;

    let (state, builder) = host.state_and_builder();
    for (token_id, mint_param) in params.tokens {
        // Mint the token in the state.
        state.mint(&token_id, &mint_param, &params.owner, builder);

        // Event for minted token.
        logger.log(&Cis2Event::Mint(MintEvent {
            token_id,
            amount: mint_param.token_amount,
            owner: params.owner,
        }))?;

        // Metadata URL for the token.
        logger.log(&Cis2Event::TokenMetadata::<_, ContractTokenAmount>(TokenMetadataEvent {
            token_id,
            metadata_url: mint_param.metadata_url.first().unwrap().clone(),
        }))?;
    }
    Ok(())
}

Step 7: Implementing Add Metadata Entry Point

Enable the contract owner to add a new MetadataUrl to the list of MetadataUrls using the following code:

/// The parameter for the contract function `addMetadata` which adds a Metadata
/// URL to the list of Metadata URLs
#[derive(Serial, Deserial, SchemaType)]
pub struct AddParams {
    tokens: collections::BTreeMap<ContractTokenId, MetadataUrl>,
}

/// The `addMetadata` function adds new MetadataUrls to the state of different
/// token_ids.
#[receive(
    contract = "cis2_dynamic_nft",
    name = "addMetadata",
    parameter = "AddParams",
    error = "ContractError",
    enable_logger,
    mutable
)]
fn contract_add_metadata<S: HasStateApi>(
    ctx: &impl HasReceiveContext,
    host: &mut impl HasHost<State<S>, StateApiType = S>,
    logger: &mut impl HasLogger,
) -> ContractResult<()> {
    // Get the contract owner
    let owner = ctx.owner();
    // Get the sender of the transaction
    let sender = ctx.sender();

    ensure!(sender.matches_account(&owner), ContractError::Unauthorized);

    // Parse the parameter.
    let params: AddParams = ctx.parameter_cursor().get()?;

    let state = host.state_mut();
    for (token_id, metadata) in params.tokens {
        // Add metadata
        state.add_metadata(&token_id, &metadata)?;

        // Metadata URL for the token.
        logger.log(&Cis2Event::TokenMetadata::<_, ContractTokenAmount>(TokenMetadataEvent {
            token_id,
            metadata_url: metadata,
        }))?;
    }
    Ok(())
}

Step 8: Implementing Upgrade Entry Point

As listed in the desired functionalities above, the contract owner should be able to invoke the upgrade function. This entry point triggers the state's upgrade function, thereby incrementing the index.

Use the following code:

pub type TokenUpdateParams = ContractTokenId;

/// Only the contract owner can call the `upgrade` function to pint next item in
/// the MetadataUrl list. It requires only tokenId, callable once at a time for
/// a token.

#[receive(
    contract = "cis2_dynamic_nft",
    name = "upgrade",
    parameter = "TokenUpdateParams",
    error = "ContractError",
    enable_logger,
    mutable
)]
fn contract_upgrade<S: HasStateApi>(
    ctx: &impl HasReceiveContext,
    host: &mut impl HasHost<State<S>, StateApiType = S>,
    logger: &mut impl HasLogger,
) -> ContractResult<()> {
    // Get the contract owner
    let owner = ctx.owner();
    // Get the sender of the transaction
    let sender = ctx.sender();

    ensure!(sender.matches_account(&owner), ContractError::Unauthorized);

    // Parse the parameter.

    let token_id: TokenUpdateParams = ctx.parameter_cursor().get()?;

    let state = host.state_mut();

    // Upgrades the given token in the state
    state.upgrade_token(&token_id)?;

    logger.log(&Cis2Event::TokenMetadata::<_, ContractTokenAmount>(TokenMetadataEvent {
        token_id,
        metadata_url: state.get_metadata(&token_id)?,
    }))?;
    Ok(())
}

Step 9: Implementing Token Metadata Entry Point

Modify the okenMetadata function to return the latest element of the list of MetadataUrls for a given token ID. Use the following code:

/// Get the token metadata URL and checksums given a list of token IDs.
///
/// It rejects if:
/// - It fails to parse the parameter.
/// - Any of the queried `token_id` does not exist.
#[receive(
    contract = "cis2_dynamic_nft",
    name = "tokenMetadata",
    parameter = "ContractTokenMetadataQueryParams",
    return_value = "TokenMetadataQueryResponse",
    error = "ContractError"
)]
fn contract_token_metadata<S: HasStateApi>(
    ctx: &impl HasReceiveContext,
    host: &impl HasHost<State<S>, StateApiType = S>,
) -> ContractResult<TokenMetadataQueryResponse> {
    // Parse the parameter.
    let params: ContractTokenMetadataQueryParams = ctx.parameter_cursor().get()?;
    // Build the response.
    let mut response = Vec::with_capacity(params.queries.len());
    let state = host.state();

    for token_id in params.queries {
        let token_metadata_url = match state.tokens.get(&token_id) {
            Some(token_metadata_url) => token_metadata_url.token_metadata_list
                [token_metadata_url.token_metadata_current_state_counter as usize]
                .clone(),
            None => bail!(ContractError::InvalidTokenId),
        };
        response.push(token_metadata_url);
    }
    let result = TokenMetadataQueryResponse::from(response);
    Ok(result)
}

Step 10: Implementing Token Metadata List Entry Point

As the final step of the requirements, create an entry point to return the list of MetadataUrls contained by any token from its initial state. You'll need to do the following:

  • Create a helper struct that holds the MetadataUrls of queried tokens.

  • Implement an tokenMetadataList entry point that iterates through the given token IDs.

  • Add the MetadataUrls to the final vector for return.

Use the following code:

#[derive(Serialize, SchemaType)]
struct TokenMetadataList {
    all_tokens_metadatas: Vec<Vec<MetadataUrl>>,
}
/// Get the complete Metadata URLs for given token_ids
/// It rejects if:
/// - It fails to parse the parameter.
/// - Any of the queried `token_id` does not exist.
#[receive(
    contract = "cis2_dynamic_nft",
    name = "tokenMetadataList",
    parameter = "ContractTokenMetadataQueryParams",
    return_value = "TokenMetadataList",
    error = "ContractError"
)]
fn contract_token_metadata_list<S: HasStateApi>(
    ctx: &impl HasReceiveContext,
    host: &impl HasHost<State<S>, StateApiType = S>,
) -> ContractResult<TokenMetadataList> {
    // Parse the parameter.
    let params: ContractTokenMetadataQueryParams = ctx.parameter_cursor().get()?;
    // Build the response.
    let mut response = Vec::with_capacity(params.queries.len());
    let state = host.state();

    for token_id in params.queries {
        let token_metadata_url = match state.tokens.get(&token_id) {
            Some(token_metadata_url) => token_metadata_url.token_metadata_list.clone(),
            None => bail!(ContractError::InvalidTokenId),
        };
        response.push(token_metadata_url);
    }
    Ok(TokenMetadataList {
        all_tokens_metadatas: response,
    })
}

Last updated