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.
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:
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.
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")]pubstructState<S> {/// The state of addresses.pub(crate) state:StateMap<Address, AddressState<S>, S>,/// All of the token IDspub(crate) tokens:StateMap<ContractTokenId, MetadataUrl, S>,/// Map with tokenId and token amount for the supplypub(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:
Add functions to the contract state for handling collateral and token supply, including the following new additions:
add_collateral(): This function expects the token contract address, token ID, owner address, and the token amount to be locked.
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.
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.
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.
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.
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) fnmint(&mut self, token_id:&ContractTokenId, token_metadata:&TokenMetadata, amount:ContractTokenAmount, owner:&Address, state_builder:&mutStateBuilder<S>, ) { { self.tokens.insert(*token_id, token_metadata.to_metadata_url());letmut owner_state = self.state.entry(*owner).or_insert_with(|| AddressState::empty(state_builder));letmut 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:
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;usecrate::{ state::{CollateralKey, CollateralState},ContractTokenAmount, ContractTokenId,};#[derive(Serial, Deserial, SchemaType)]pubstructTokenMintParams {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)]pubstructMintParams {/// 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)]pubstructSetImplementorsParams {/// 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)]pubstructTokenMetadata {/// 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,}implTokenMetadata {fnget_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) fnto_metadata_url(&self) ->MetadataUrl {MetadataUrl { url: self.url.to_string(), hash: self.get_hash_bytes(), } }}#[derive(Serialize, SchemaType)]pubstructViewAddressState {pub balances:Vec<(ContractTokenId, ContractTokenAmount)>,pub operators:Vec<Address>,}#[derive(Serialize, SchemaType)]pubstructViewState {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.pubtypeContractBalanceOfQueryParams=BalanceOfQueryParams<ContractTokenId>;/// Response type for the CIS-2 function `balanceOf` specialized to the subset/// of TokenAmounts used by this contract.pubtypeContractBalanceOfQueryResponse=BalanceOfQueryResponse<ContractTokenAmount>;pubtypeTransferParameter=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:
pubenumCustomContractError {/// 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 tokenIDTokenAlreadyMinted,/// Cant be collateralizedInvalidCollateral,/// Same collateral ID twiceAlreadyCollateralized,/// Cant burnNoBalanceToBurn,/// Contracts are not allowedAccountsOnly,/// Cant call another CIS-2 contractCis2ClientError(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::*;usecrate::state::State;pubconst TRANSFER_ENTRYPOINT_NAME:&str="transfer";#[derive(Serialize, Debug, PartialEq, Eq, Reject, SchemaType)]pubenumCis2ClientError {InvokeContractError,ParseParams,}pubstructCis2Client;implCis2Client {pub(crate) fntransfer<S,T:IsTokenId+Clone+Copy,A:IsTokenAmount+Clone+Copy+ ops::Sub<Output=A>, >( host:&mutimplHasHost<State<S>, StateApiType=S>, token_id:T, nft_contract_address:ContractAddress, amount:A, from:Address, to:Receiver, ) ->Result<(), Cis2ClientError>whereS: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,¶ms, )?;Ok(()) }fninvoke_contract_read_only<S:HasStateApi, R:Deserial, P:Serial>( host:&mutimplHasHost<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)?;letmut invoke_contract_res =match invoke_contract_result {Some(s) => s,None=>returnResult::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:
Account Verification: Ensure that only accounts can lock and fractionalize the NFTs. This is achieved using a match statement to control the flow.
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.
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)]fncontract_mint<S:HasStateApi>( ctx:&implHasReceiveContext, host:&mutimplHasHost<State<S>, StateApiType=S>, logger:&mutimplHasLogger,) ->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 tokenensure!( 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,¶ms.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:
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.
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.
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)]fncontract_transfer<S:HasStateApi>( ctx:&implHasReceiveContext, host:&mutimplHasHost<State<S>, StateApiType=S>, logger:&mutimplHasLogger,) ->ContractResult<()> {// Parse the parameter.letTransferParams(transfers):TransferParameter= ctx.parameter_cursor().get()?;// Get the sender who invoked this contract function.let sender = ctx.sender();forTransfer { token_id, amount, from, to, data, } in transfers {let (state, builder) = host.state_and_builder();// Authenticate the sender for this transferensure!( 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 tokenslet 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 amountif remaining_amount.eq(&ContractTokenAmount::from(0)) {// Everything has been burned// Transfer collateral back to the original ownerlet (collateral_key, collateral_amount) = state.find_collateral(&token_id).ok_or(Cis2Error::Custom(CustomContractError::InvalidCollateral))?;// Return back the collateralCis2Client::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.ifletReceiver::Contract(address, entrypoint_name) = to {let parameter =OnReceivingCis2Params { token_id, amount, from, data, }; host.invoke_contract(&address,¶meter, entrypoint_name.as_entrypoint_name(), Amount::zero(), )?; } } }Ok(())}