Skip to main content

Gear Multiple Token (gMT)

Introduction

A standard interface for contracts that manage multiple token types. A single deployed contract may include any combination of fungible tokens, non-fungible tokens or other configurations (e.g. semi-fungible tokens).

The idea is simple and seeks to create a smart contract interface that can represent and control any number of fungible and non-fungible token types. In this way, the gMT token can do the same functions as a GFT and GNFT token, and even both at the same time. Can be considered as analog of ERC-1155.

This article explains the programming interface, data structure, basic functions and explains their purpose. It can be used as is or modified to suit your own scenarios. Anyone can easily create their own application and run it on the Gear Network. The source code is available on GitHub.

Default multitoken implementation

The functions that must be supported by each multi-token contract:

  • mint(to, []token_id, []metadata, []amounts) - is a function that creates single/multiple new tokens (with the corresponding supply from amounts array). Metadata can include any information about the token: it can be a link to a specific resource, a description of the token, etc. Metadata is stored for NFTs only;
  • burn(from, []token_id, []amounts) - is a function that removes the specified amounts of tokens with mentioned ids from the contract;
  • transfer(to, []token_id, []amounts) - is a function that allows you to transfer tokens with the token_id to the to account;
  • approve/revoke approval(approved account, token_id) - is a function that allows you to give the right to dispose of the token to the specified approved_account. This functionality can be useful on marketplaces or auctions as when the owner wants to sell his token, they can put it on a marketplace/auction, so the contract will be able to send this token to the new owner at some point;
  • balance(account) - is a function that returns the ids and amounts of different tokens a user has in possession;

The default implementation of the NFT contract is provided in the Gear library: gear-lib/multitoken.

To use the default implementation you should include the packages into your Cargo.toml file:

gear-contract-libraries = { path = "https://github.com/gear-dapps/gear-lib" }
derive_traits = { path = "https://github.com/gear-dapps/gear-lib/tree/master/derive" }

The states that multitoken contract store are defined in the struct MTKState:

#[derive(Debug, Default)]
pub struct MTKState {
pub name: String,
pub symbol: String,
pub base_uri: String,
pub balances: BTreeMap<TokenId, BTreeMap<ActorId, u128>>,
pub approvals: BTreeMap<ActorId, BTreeMap<ActorId, bool>>,
pub token_metadata: BTreeMap<TokenId, TokenMetadata>,
// owner for nft
pub owners: BTreeMap<TokenId, ActorId>,
}

To reuse the default struct you need derive the MTKTokenState trait and mark the corresponding field with the #[MTKStateKeeper] attribute. You can also add your fields in your MTK contract. For example, let's add the owner's address to the contract, the token_id that will track the current number of token and the supply to store how many of differnt tokens were minted:

use derive_traits::{MTKCore, MTKTokenState, StateKeeper};
use gear_contract_libraries::multitoken::{io::*, mtk_core::*, state::*};


#[derive(Debug, Default, MTKTokenState, MTKCore, StateKeeper)]
pub struct SimpleMTK {
#[MTKStateKeeper]
pub tokens: MTKState,
pub token_id: TokenId,
pub owner: ActorId,
pub supply: BTreeMap<TokenId, u128>,
}

To inherit the default logic functions you need to derive MTKCore trait. Accordingly, for reading contracts states you need MTKTokenState trait.

Let's write the whole implementation of the MTK contract. First, we define the message which will initialize the contract and messages that our contract will process:

#[derive(Debug, Encode, Decode, TypeInfo)]
pub struct InitMTK {
pub name: String,
pub symbol: String,
pub base_uri: String,
}

#[derive(Debug, Encode, Decode, TypeInfo)]
pub enum MTKAction {
Mint {
token_id: TokenId,
amount: u128,
token_metadata: Option<TokenMetadata>,
},
Burn {
token_id: TokenId,
amount: u128,
},
BalanceOf {
account: ActorId,
id: TokenId,
},
BalanceOfBatch {
accounts: Vec<ActorId>,
ids: Vec<TokenId>,
},
MintBatch {
ids: Vec<TokenId>,
amounts: Vec<u128>,
tokens_metadata: Vec<Option<TokenMetadata>>,
},
TransferFrom {
from: ActorId,
to: ActorId,
id: TokenId,
amount: u128,
},
BatchTransferFrom {
from: ActorId,
to: ActorId,
ids: Vec<TokenId>,
amounts: Vec<u128>,
},
BurnBatch {
ids: Vec<TokenId>,
amounts: Vec<u128>,
},
Approve {
account: ActorId,
},
RevokeApproval {
account: ActorId,
},
}

Then the default MTK contract implementation:

#[derive(Debug, Default, MTKTokenState, MTKCore, StateKeeper)]
pub struct SimpleMTK {
#[MTKStateKeeper]
pub tokens: MTKState,
pub token_id: TokenId,
pub owner: ActorId,
pub supply: BTreeMap<TokenId, u128>,
}

static mut CONTRACT: Option<SimpleMTK> = None;

#[no_mangle]
pub unsafe extern "C" fn init() {
let config: InitMTK = msg::load().expect("Unable to decode InitConfig");
let mut multi_token = SimpleMTK::default();
multi_token.tokens.name = config.name;
multi_token.tokens.symbol = config.symbol;
multi_token.tokens.base_uri = config.base_uri;
multi_token.owner = msg::source();
CONTRACT = Some(multi_token);
}

#[no_mangle]
pub unsafe extern "C" fn handle() {
let action: MTKAction = msg::load().expect("Could not load msg");
let multi_token = CONTRACT.get_or_insert(SimpleMTK::default());
match action {
MTKAction::Mint {
amount,
token_metadata,
} => MTKCore::mint(multi_token, token_id, amount, token_metadata),
MTKAction::Burn { token_id, amount } => MTKCore::burn(multi_token, token_id, amount),
MTKAction::BalanceOf { account, id } => {
MTKCore::balance_of(multi_token, vec![account], vec![id])
}
MTKAction::BalanceOfBatch { accounts, ids } => {
MTKCore::balance_of(multi_token, accounts, ids)
}
MTKAction::MintBatch {
ids,
amounts,
tokens_metadata,
} => MTKCore::mint(multi_token, &msg::source(), ids, amounts, tokens_metadata),
MTKAction::TransferFrom {
from,
to,
id,
amount,
} => MTKCore::transfer_from(multi_token, &from, &to, vec![id], vec![amount]),
MTKAction::BatchTransferFrom {
from,
to,
ids,
amounts,
} => MTKCore::transfer_from(multi_token, &from, &to, ids, amounts),
MTKAction::BurnBatch { ids, amounts } => MTKCore::burn(multi_token, ids, amounts),
MTKAction::Approve { account } => MTKCore::approve(multi_token, &account),
MTKAction::RevokeApproval { account } => MTKCore::revoke_approval(multi_token, &account),
}
}

Developing your multitoken contract

Next, let's rewrite the implementation of mint and burn functions and also add a transform function. Our mint function will create token for the account that send Mint message and require the metadata as an input argument. As for the burn function - is will be the same as a default one, but we override it, since we want to take care of the supply when we actually burn the token from the contract. Newly introduces transform function is used for converting FT to NFTs. Provided FT's are burnt from a user's account and multiple NFTs can be minted (but not more than the amount of burnt tokens).


/// Element of transform function.
#[derive(Debug, Encode, Decode, TypeInfo)]
pub struct BurnToNFT {
/// To which account mint NFTs.
pub account: ActorId,
/// NFTs' IDs.
pub nfts_ids: Vec<TokenId>,
/// NFTs' metadata.
pub nfts_metadata: Vec<Option<TokenMetadata>>,
}

pub enum MTKAction {
/// Mints a token.
///
/// # Requirements:
/// * if minting an NFT `amount` MUST equal to 1.
/// * a sender MUST be an owner or an approved account.
///
/// On success returns `MTKEvent::Transfer`.
Mint {
/// Token amount.
amount: u128,
/// Token metadata, applicable if minting an NFT.
token_metadata: Option<TokenMetadata>,
},

/// Burns a token.
///
/// # Requirements:
/// * a sender MUST have sufficient amount of token to burn.
/// * a sender MUST be the owner.
///
/// On success returns `MTKEvent::Transfer`.
Burn {
/// Token ID.
id: TokenId,
/// Amount of token to be burnt.
amount: u128,
},
/// Transforms user's tokens to multiple NFTs.
///
/// # Requirements:
/// * a sender MUST have sufficient amount of tokens to burn,
/// * a sender MUST be the owner.
///
/// On success returns `MTKEvent::Transfer`.
Transform {
/// Token's ID to burn.
id: TokenId,
/// Amount of burnt token.
amount: u128,
/// NFT minting data.
nfts: Vec<BurnToNFT>,
},
}

The TokenMetadata is also defined in the Gear MTK library:

#[derive(Debug, Default, Encode, Decode, Clone, TypeInfo)]
pub struct TokenMetadata {
// ex. "CryptoKitty #100"
pub name: String,
// free-form description
pub description: String,
// URL to associated media, preferably to decentralized, content-addressed storage
pub media: String,
// URL to an off-chain JSON file with more info.
pub reference: String,
}

Define a trait for our new function that will extend the default MTKCore trait:

pub trait SimpleMTKCore: MTKCore {
fn mint(&mut self, amount: u128, token_metadata: Option<TokenMetadata>);

fn burn(&mut self, id: TokenId, amount: u128);

fn transform(&mut self, id: TokenId, amount: u128, nfts: Vec<BurnToNFT>);
}

and write the implementation:

impl SimpleMTKCore for SimpleMTK {
/// Mints a token.
///
/// Arguments:
/// * `account`: Which account to mint tokens to. Default - `msg::source()`,
/// * `amount`: Token amount. In case of NFT - 1.
/// * `token_metadata`: Token metadata, only applicable when minting an NFT. Otherwise - `None`.
fn mint(&mut self, account: ActorId, amount: u128, token_metadata: Option<TokenMetadata>) {
MTKCore::mint(
self,
&account,
vec![(self.token_id)],
vec![amount],
vec![token_metadata],
);
self.supply.insert(self.token_id, amount);
self.token_id = self.token_id.saturating_add(1);
}

/// Burns a token.
///
/// Requirements:
/// * sender MUST have sufficient amount of token.
///
/// Arguments:
/// * `id`: Token ID.
/// * `amount`: Token's amount to be burnt.
fn burn(&mut self, id: TokenId, amount: u128) {
MTKCore::burn(self, vec![id], vec![amount]);
let sup = self.supply(id);
let mut _balance = self
.supply
.insert(self.token_id, sup.saturating_sub(amount));
}

/// Transforms FT tokens to multiple NFTs.
///
/// Requirements:
/// * a sender MUST have sufficient amount of tokens to burn,
/// * a sender MUST be the owner.
///
/// Arguments:
/// * `id`: Token ID.
/// * `amount`: Token's amount to be burnt.
/// * `accounts`: To which accounts to mint NFT.
/// * `nft_ids`: NFTs' IDs to be minted.
/// * `nfts_metadata`: NFT's metadata.
fn transform(&mut self, id: TokenId, amount: u128, nfts: Vec<BurnToNFT>) {
// pre-checks
let mut nft_count = 0;
for info in &nfts {
nft_count += info.nfts_ids.len();
}
if amount as usize != nft_count {
panic!("MTK: amount of burnt tokens MUST be equal to the amount of nfts.");
}

// burn FT (not to produce another message - just simply use burn_impl)
self.assert_can_burn(&msg::source(), &id, amount);
self.burn_impl(&id, amount);

let sup = self.supply(id);
let mut _balance = self
.supply
.insert(self.token_id, sup.saturating_sub(amount));
let mut ids = vec![];

for burn_info in nfts {
if burn_info.account.is_zero() {
panic!("MTK: Mint to zero address");
}
if burn_info.nfts_ids.len() != burn_info.nfts_metadata.len() {
panic!("MTK: ids and amounts length mismatch");
}
burn_info
.nfts_metadata
.into_iter()
.enumerate()
.for_each(|(i, meta)| {
self.mint_impl(&burn_info.account, &burn_info.nfts_ids[i], NFT_COUNT, meta)
});
for id in burn_info.nfts_ids {
ids.push(id);
}
}

msg::reply(
MTKEvent::Transfer {
operator: msg::source(),
from: ActorId::zero(),
to: ActorId::zero(),
ids: ids.to_vec(),
amounts: vec![NFT_COUNT; amount as usize],
},
0,
)
.expect("Error during a reply with MTKEvent::Transfer");
}
}

Accordingly, it is necessary to make changes to the handle function:

    // code before remains the same...
MTKAction::Mint {
amount,
token_metadata,
} => SimpleMTKCore::mint(multi_token, amount, token_metadata),
MTKAction::Burn { id, amount } => SimpleMTKCore::burn(multi_token, id, amount),
MyMTKAction::Transform { id, amount, nfts } => {
SimpleMTKCore::transform(multi_token, id, amount, nfts)
}
// code after remains the same...

Conclusion

Gear provides a reusable library with core functionality for the gMT protocol. By using object composition, that library can be utilized within a custom gMT/MTK contract implementation in order to minimize duplication of community available code.

A source code of the base lib providing multitoken functionality provided by Gear is available on GithHub: gear-lib/src/multitoken.

A source code of the contract example provided by Gear is available on GitHub: multitoken/src.

See also an example of the smart contract testing implementation based on gtest: multitoken/tests.

For more details about testing smart contracts written on Gear, refer to this article: Program Testing.