πŸ“œSmart Contract

This tutorial teaches you how to implement a smart contract for managing assets on the Concordium blockchain, covering features like collateralization and asset fractionalization.

Step 1: Introduction to Smart Contract Structure

In this tutorial, you'll organize the smart contract in a specific way:

  • lib.rs: This file serves as the main function of your contract, similar to other programming languages. The compiler starts from this file to compile it.

  • contract.rs: This file houses your primary CIS-2 contract, containing all the logic required for the contract's functionality.

  • state.rs: Here, you keep the contract state, including various maps such as state, tokens, token_supply, implementors, and collaterals. You'll keep these separate for demonstration purposes, but you can consolidate them into lib.rs if preferred.

  • cis2_client.rs: This file facilitates operations between the master and CIS-2 token contracts.

Check out this GitHub repository for the contracts.

Step 2: Implementation of State

In the "state.rs" file, you'll manage the contract's state, which holds the latest state of your assets. Here's an overview of the state initialization and structure:

  1. Initialization: You'll create an empty state at the beginning, including four maps:

    • state: Manages the state of addresses.

    • tokens: Stores all token IDs.

    • token_supply: Maps token IDs to their respective supply amounts.

    • implementors: Tracks implementors of specific standards.

    • collaterals: Handles tokens to be locked.

  2. Structure: The collaterals map practically stores tokens to be locked, utilizing the StateMap<CollateralKey, CollateralState, S> structure.

Copy the following code and paste it into your "state.rs" file to structure your state:

/// 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, StateClone)]
#[concordium(state_parameter = "S")]
pub struct State<S> {
    /// The state of addresses.
    pub(crate) state: StateMap<Address, AddressState<S>, S>,
    /// All of the token IDs
    pub(crate) tokens: StateMap<ContractTokenId, MetadataUrl, S>,
    /// Map with tokenId and token amount for the supply
    pub(crate) token_supply: StateMap<ContractTokenId, ContractTokenAmount, S>,
    pub(crate) implementors: StateMap<StandardIdentifierOwned, Vec<ContractAddress>, S>,
    pub(crate) collaterals: StateMap<CollateralKey, CollateralState, S>,
}

Step 3: Collateral Handling

Introduce collateral handling to allow tokens to be locked. Here's how you structure the collateral state:

  • You'll create a new state variable to manage collateralized tokens represented by the CollateralKey struct. This struct includes fields for the token's contract address, ID, and the owner who locked it.

  • Additionally, you define the CollateralState struct to track the received token amount and the minted token ID.

  • The new() function initializes the CollateralState with default values, and it's invoked when adding new collateral to mint fractions.

In your state.rs file, add the following code to structure your collateral state:

#[derive(Serial, Deserial, Clone, SchemaType, Copy)]
pub struct CollateralKey {
    pub contract: ContractAddress,
    pub token_id: ContractTokenId,
    pub owner: AccountAddress,
}

#[derive(Serial, Deserial, Clone, Copy, SchemaType)]
pub struct CollateralState {
    pub received_token_amount: ContractTokenAmount,
    pub minted_token_id: Option<ContractTokenId>,
}

impl CollateralState {
    fn new() -> Self {
        CollateralState {
            received_token_amount: ContractTokenAmount::from(0),
            minted_token_id: Option::None,
        }
    }
}

Step 4: Additional State Functions

Add functions to the contract state for handling collateral and token supply, including the following new additions:

  1. add_collateral(): This function expects the token contract address, token ID, owner address, and the token amount to be locked.

  2. has_collateral(): Similarly, this function takes the token contract address, token ID, and owner address as input to create a key in the form of the CollateralKey struct to look into the StateMap. If someone has already collateralized the token, this function will return true, ensuring they cannot collateralize it again.

  3. find_collateral(): This function takes the token ID (fraction) as a parameter and checks its existence in the minted tokens. If a token with that ID exists, it returns a clone of it.

  4. has_fractions(): You will use this function to check whether a token is already fractionalized into new ones. You don't want to allow people to create more and more fractions when they lock their assets once.

  5. update_collateral_token(): You will use this function when you have locked the tokens while minting new fractions. Based on the amount of fractions, it will update the state with the new tokens.

It's important to note that you can technically lock a semi-fungible token with this example. If you'd like to limit the locking mechanism, you can adjust it by simply checking the amount.

In your "state.rs" file, add the following code:

    pub(crate) fn add_collateral(
        &mut self,
        contract: ContractAddress,
        token_id: ContractTokenId,
        owner: AccountAddress,
        received_token_amount: ContractTokenAmount,
    ) {
        let key = CollateralKey {
            contract,
            token_id,
            owner,
        };

        let mut cs = match self.collaterals.get(&key) {
            Some(v) => *v,
            None => CollateralState::new(),
        };

        cs.received_token_amount += received_token_amount;

        self.collaterals.insert(key, cs);
    }

    pub(crate) fn has_collateral(
        &self,
        contract: &ContractAddress,
        token_id: &ContractTokenId,
        owner: &AccountAddress,
    ) -> bool {
        let key = CollateralKey {
            contract: *contract,
            token_id: *token_id,
            owner: *owner,
        };

        self.collaterals.get(&key).is_some()
    }

    pub(crate) fn find_collateral(
        &self,
        token_id: &ContractTokenId,
    ) -> Option<(CollateralKey, ContractTokenAmount)> {
        for c in self.collaterals.iter() {
            match c.1.minted_token_id {
                Some(t) => {
                    if t.eq(token_id) {
                        return Some((c.0.clone(), c.1.received_token_amount));
                    }
                }
                None => continue,
            };
        }

        None
    }

    pub(crate) fn has_fraction(
        &self,
        contract: &ContractAddress,
        token_id: &ContractTokenId,
        owner: &AccountAddress,
    ) -> Option<ContractTokenId> {
        let key = CollateralKey {
            contract: *contract,
            token_id: *token_id,
            owner: *owner,
        };

        self.collaterals.get(&key)?.minted_token_id
    }

    pub(crate) fn update_collateral_token(
        &mut self,
        contract: ContractAddress,
        token_id: ContractTokenId,
        owner: AccountAddress,
        minted_token_id: ContractTokenId,
    ) -> ContractResult<()> {
        let key = CollateralKey {
            contract,
            token_id,
            owner,
        };

        match self.collaterals.entry(key) {
            Entry::Vacant(_) => bail!(Cis2Error::Custom(CustomContractError::InvalidCollateral)),
            Entry::Occupied(mut e) => {
                e.modify(|s| s.minted_token_id = Some(minted_token_id));
                Ok(())
            }
        }
}

Step 5: Token Supply Management

Implement helper functions for managing token supply by adding the following code to your "state.rs" file:

  fn increase_supply(&mut self, token_id: ContractTokenId, amount: ContractTokenAmount) {
        let curr_supply = self.get_supply(&token_id);
        self.token_supply.insert(token_id, curr_supply + amount);
    }
  fn decrease_supply(&mut self, token_id: ContractTokenId, amount: ContractTokenAmount) {
        let curr_supply = self.get_supply(&token_id);
        let remaining_supply = curr_supply - amount;
        if remaining_supply.cmp(&ContractTokenAmount::from(0)).is_eq() {
            self.token_supply.remove(&token_id);
        } else {
            self.token_supply.insert(token_id, curr_supply - amount);
        }
    }
   pub(crate) fn get_supply(&self, token_id: &ContractTokenId) -> ContractTokenAmount {
        match self.token_supply.get(token_id) {
            Some(amount) => *amount,
            None => ContractTokenAmount::from(0),
        }
    }

Step 6: State Mint Function

Modify the state's mint() function to handle the minting of tokens, and add the increase_supply() function. To do this, in your "state.rs" file, include the following code:

   /// Mints an amount of tokens with a given address as the owner.
    pub(crate) fn mint(
        &mut self,
        token_id: &ContractTokenId,
        token_metadata: &TokenMetadata,
        amount: ContractTokenAmount,
        owner: &Address,
        state_builder: &mut StateBuilder<S>,
    ) {
        {
            self.tokens
                .insert(*token_id, token_metadata.to_metadata_url());
            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 += amount;
        }

        self.increase_supply(*token_id, amount);
    }

Step 7: State Burn Function

Add a burn() function to the state for burning fractions of tokens. This function utilizes the decrease_supply() function to update the state when tokens are burned:

pub(crate) fn burn(
    &mut self,
    token_id: &ContractTokenId,
    amount: ContractTokenAmount,
    owner: &Address,
) -> ContractResult<ContractTokenAmount> {
    let ret = {
        match self.state.get_mut(owner) {
            Some(address_state) => match address_state.balances.get_mut(token_id) {
                Some(mut b) => {
                    ensure!(
                        b.cmp(&amount).is_ge(),
                        Cis2Error::Custom(CustomContractError::NoBalanceToBurn)
                    );

                    *b -= amount;
                    Ok(*b)
                }
                None => Err(Cis2Error::Custom(CustomContractError::NoBalanceToBurn)),
            },
            None => Err(Cis2Error::Custom(CustomContractError::NoBalanceToBurn)),
        }
    };

    self.decrease_supply(*token_id, amount);

    ret
}

Step 8: Parameter Handling

Create a "params.rs" file to manage parameter structures and implementations. This file will contain parameter structs and implementations for minting, metadata operations, and view. They are similar to CIS2-multi parameters with some additions for handling collaterals.

Paste the following code in the file:

use concordium_cis2::*;
use concordium_std::*;
use core::convert::TryInto;

use crate::{
    state::{CollateralKey, CollateralState},
    ContractTokenAmount, ContractTokenId,
};

#[derive(Serial, Deserial, SchemaType)]
pub struct TokenMintParams {
    pub metadata: TokenMetadata,
    pub amount: ContractTokenAmount,
    pub contract: ContractAddress,
    pub token_id: ContractTokenId,
}

/// 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, TokenMintParams>,
}

/// The parameter type for the contract function `setImplementors`.
/// Takes a standard identifier and a list of contract addresses providing
/// implementations of this standard.
#[derive(Debug, Serialize, SchemaType)]
pub struct SetImplementorsParams {
    /// The identifier for the standard.
    pub id: StandardIdentifierOwned,
    /// The addresses of the implementors of the standard.
    pub implementors: Vec<ContractAddress>,
}

#[derive(Debug, Serialize, Clone, SchemaType)]
pub struct TokenMetadata {
    /// The URL following the specification RFC1738.
    #[concordium(size_length = 2)]
    pub url: String,
    /// A optional hash of the content.
    #[concordium(size_length = 2)]
    pub hash: String,
}

impl TokenMetadata {
    fn get_hash_bytes(&self) -> Option<[u8; 32]> {
        match hex::decode(self.hash.to_owned()) {
            Ok(v) => {
                let slice = v.as_slice();
                match slice.try_into() {
                    Ok(array) => Option::Some(array),
                    Err(_) => Option::None,
                }
            }
            Err(_) => Option::None,
        }
    }

    pub(crate) fn to_metadata_url(&self) -> MetadataUrl {
        MetadataUrl {
            url: self.url.to_string(),
            hash: self.get_hash_bytes(),
        }
    }
}

#[derive(Serialize, SchemaType)]
pub struct ViewAddressState {
    pub balances: Vec<(ContractTokenId, ContractTokenAmount)>,
    pub operators: Vec<Address>,
}

#[derive(Serialize, SchemaType)]
pub struct ViewState {
    pub state: Vec<(Address, ViewAddressState)>,
    pub tokens: Vec<ContractTokenId>,
    pub collaterals: Vec<(CollateralKey, CollateralState)>,
}

/// Parameter type for the CIS-2 function `balanceOf` specialized to the subset
/// of TokenIDs used by this contract.
pub type ContractBalanceOfQueryParams = BalanceOfQueryParams<ContractTokenId>;

/// Response type for the CIS-2 function `balanceOf` specialized to the subset
/// of TokenAmounts used by this contract.
pub type ContractBalanceOfQueryResponse = BalanceOfQueryResponse<ContractTokenAmount>;

pub type TransferParameter = TransferParams<ContractTokenId, ContractTokenAmount>;

Step 9: Error Handling

Create an "error.rs" file to implement custom errors for this project. To understand the use cases of these custom errors, refer to the documentation.

Add the following code to your "error.rs" file:

pub enum CustomContractError {
    /// Failed parsing the parameter.
    #[from(ParseError)]
    ParseParams,
    /// Failed logging: Log is full.
    LogFull,
    /// Failed logging: Log is malformed.
    LogMalformed,
    /// Invalid contract name.
    InvalidContractName,
    /// Only a smart contract can call this function.
    ContractOnly,
    /// Failed to invoke a contract.
    InvokeContractError,
    /// Unique tokenID
    TokenAlreadyMinted,
    /// Cant be collateralized
    InvalidCollateral,
    /// Same collateral ID twice
    AlreadyCollateralized,
    /// Cant burn
    NoBalanceToBurn,
    /// Contracts are not allowed
    AccountsOnly,
    /// Cant call another CIS-2 contract
    Cis2ClientError(Cis2ClientError),
}

Step 10: CIS-2 Client Implementation

Create the "cis2_client.rs" file to implement the CIS-2 client for interacting with other smart contracts. This file serves as a relay layer, performing the following tasks:

  • Facilitating the calling of a contract from another smart contract.

  • Providing functionality to transfer assets back to the original owner when all fractions are burned.

  • Ensuring that assets are transferred using the contract that minted the original token.

To achieve this, add the following code to your "cis2_client.rs" file:

//! CIS2 client is the intermediatory layer between fractionalizer contract and CIS2 contract.
//!
//! # Description
//! It allows Fractionalizer contract to abstract away logic of calling CIS2 contract for the following methods
//! - `transfer` : Calls [`transfer`](https://proposals.concordium.software/CIS/cis-2.html#transfer)

use std::vec;

use concordium_cis2::*;
use concordium_std::*;

use crate::state::State;

pub const TRANSFER_ENTRYPOINT_NAME: &str = "transfer";

#[derive(Serialize, Debug, PartialEq, Eq, Reject, SchemaType)]
pub enum Cis2ClientError {
    InvokeContractError,
    ParseParams,
}

pub struct Cis2Client;

impl Cis2Client {
    pub(crate) fn transfer<
        S,
        T: IsTokenId + Clone + Copy,
        A: IsTokenAmount + Clone + Copy + ops::Sub<Output = A>,
    >(
        host: &mut impl HasHost<State<S>, StateApiType = S>,
        token_id: T,
        nft_contract_address: ContractAddress,
        amount: A,
        from: Address,
        to: Receiver,
    ) -> Result<(), Cis2ClientError>
    where
        S: HasStateApi,
        A: IsTokenAmount,
    {
        let params = TransferParams(vec![Transfer {
            token_id,
            amount,
            from,
            data: AdditionalData::empty(),
            to,
        }]);

        Cis2Client::invoke_contract_read_only(
            host,
            &nft_contract_address,
            TRANSFER_ENTRYPOINT_NAME,
            &params,
        )?;

        Ok(())
    }

    fn invoke_contract_read_only<S: HasStateApi, R: Deserial, P: Serial>(
        host: &mut impl HasHost<State<S>, StateApiType = S>,
        contract_address: &ContractAddress,
        entrypoint_name: &str,
        params: &P,
    ) -> Result<R, Cis2ClientError> {
        let invoke_contract_result = host
            .invoke_contract_read_only(
                contract_address,
                params,
                EntrypointName::new(entrypoint_name).unwrap_abort(),
                Amount::from_ccd(0),
            )
            .map_err(|_e| Cis2ClientError::InvokeContractError)?;
        let mut invoke_contract_res = match invoke_contract_result {
            Some(s) => s,
            None => return Result::Err(Cis2ClientError::InvokeContractError),
        };
        let parsed_res =
            R::deserial(&mut invoke_contract_res).map_err(|_e| Cis2ClientError::ParseParams)?;

        Ok(parsed_res)
    }
}

Step 11: Contract Implementation

Create the "contract.rs" file for the fractionalization and management of NFTs within the contract. To do this, create two functions: the "contract_mint()" and "contract_transfer()" functions.

Mint Function

In the contract_mint() function, add the following three key functionalities to facilitate the minting of fractions of NFTs:

  1. Account Verification: Ensure that only accounts can lock and fractionalize the NFTs. This is achieved using a match statement to control the flow.

  2. Collateral Check: It should be impossible to mint new fractions if the collateral is not locked first. Therefore, ensure that the token exists in your collateral list. If it doesn't, throw an InvalidCollateral custom error.

  3. State Update: Update your state when a token is minted. This involves storing information about which token from which contract is locked, which token on this contract is minted, and who the owner is. The update_collateral_token() function is used for this purpose.

To implement all these, add the following code in your "contract.rs" file:

#[receive(
    contract = "CIS2-Fractionalizer",
    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<()> {
    let sender = match ctx.sender() {
        Address::Account(a) => a,
        Address::Contract(_) => bail!(CustomContractError::AccountsOnly.into()),
    };

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

    let (state, builder) = host.state_and_builder();
    for (token_id, token_info) in params.tokens {
       ensure!(
            state.contains_token(&token_id),
            ContractError::Custom(CustomContractError::TokenAlreadyMinted)
        );    
    
        ensure!(
            state.has_collateral(&token_info.contract, &token_info.token_id, &sender),
            concordium_cis2::Cis2Error::Custom(CustomContractError::InvalidCollateral)
        );
        // create a fraction only for once for a token
        ensure!(
            state
                .has_fraction(&token_info.contract, &token_info.token_id, &sender)
                .is_none(),
            concordium_cis2::Cis2Error::Custom(CustomContractError::AlreadyCollateralized)
        );

        // Mint the token in the state.
        state.mint(
            &token_id,
            &token_info.metadata,
            token_info.amount,
            &params.owner,
            builder,
        );

        state.update_collateral_token(
            token_info.contract,
            token_info.token_id,
            sender,
            token_id,
        )?;

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

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

Transfer Function

The contract_transfer() function finalizes your contract development, combining the token transfer and burning processes:

  1. Burning Logic: When transferring fractions (tokens minted on this contract) back to the contract, it's assumed that the intention is to burn them. Only the owner of the asset can initiate this process. Authenticate the sender and then proceed to burn the tokens using the state’s burn() function. The BurnEvent is emitted after burning.

  2. Collateral Transfer: If the remaining token amount after burning is 0, indicating that all tokens have been burned, the collateral is transferred back to the original owner. This is achieved by communicating with the CIS-2 token contract to invoke the transfer function.

  3. Regular Transfer: If tokens are transferred to another address, the contract state is updated accordingly, and a transfer event is logged. If the receiver is a contract, the contract is invoked.

Add the following code to implement the Transfer function:

#[receive(
    contract = "CIS2-Fractionalizer",
    name = "transfer",
    parameter = "TransferParameter",
    error = "ContractError",
    enable_logger,
    mutable
)]
fn contract_transfer<S: HasStateApi>(
    ctx: &impl HasReceiveContext,
    host: &mut impl HasHost<State<S>, StateApiType = S>,
    logger: &mut impl HasLogger,
) -> ContractResult<()> {
    // Parse the parameter.
    let TransferParams(transfers): TransferParameter = ctx.parameter_cursor().get()?;
    // Get the sender who invoked this contract function.
    let sender = ctx.sender();

    for Transfer {
        token_id,
        amount,
        from,
        to,
        data,
    } in transfers
    {
        let (state, builder) = host.state_and_builder();
        // Authenticate the sender for this transfer
        ensure!(
            from == sender || state.is_operator(&sender, &from),
            ContractError::Unauthorized
        );

        if to.address().matches_contract(&ctx.self_address()) {
            // tokens are being transferred to self
            // burn the tokens
            let remaining_amount: ContractTokenAmount = state.burn(&token_id, amount, &from)?;

            // log burn event
            logger.log(&Cis2Event::Burn(BurnEvent {
                token_id,
                amount,
                owner: from,
            }))?;

            // Check of there is any remaining amount
            if remaining_amount.eq(&ContractTokenAmount::from(0)) {
                // Everything has been burned
                // Transfer collateral back to the original owner
                let (collateral_key, collateral_amount) = state
                    .find_collateral(&token_id)
                    .ok_or(Cis2Error::Custom(CustomContractError::InvalidCollateral))?;

                // Return back the collateral
                Cis2Client::transfer(
                    host,
                    collateral_key.token_id,
                    collateral_key.contract,
                    collateral_amount,
                    concordium_std::Address::Contract(ctx.self_address()),
                    concordium_cis2::Receiver::Account(collateral_key.owner),
                )
                .map_err(CustomContractError::Cis2ClientError)?;
            }
        } else {
            let to_address = to.address();

            // Tokens are being transferred to another address
            // Update the contract state
            state.transfer(&token_id, amount, &from, &to_address, builder)?;

            // Log transfer event
            logger.log(&Cis2Event::Transfer(TransferEvent {
                token_id,
                amount,
                from,
                to: to_address,
            }))?;

            // If the receiver is a contract we invoke it.
            if let Receiver::Contract(address, entrypoint_name) = to {
                let parameter = OnReceivingCis2Params {
                    token_id,
                    amount,
                    from,
                    data,
                };
                host.invoke_contract(
                    &address,
                    &parameter,
                    entrypoint_name.as_entrypoint_name(),
                    Amount::zero(),
                )?;
            }
        }
    }

    Ok(())
}

Refer to the documentation for more information.

Last updated