Skip to main content

Gear Multiple Token (gMT)

Introduction

A standard interface for programs that manage multiple token types. A single deployed program 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 program 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 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.

Multiple token implementation

Consider the main functionality of the program

  • mint(id, amount, token_metadata) is a function that creates a new token with the given id for the account. 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 available only in case of non-fungible tokens (amount equals 1);
  • mint_batch(ids, amounts, tokens_metadata) is a function similar to the mint function, but it creates several tokens at the same time;
  • burn(id, amount) is a function that removes the token with the mentioned id and amount from the program;
  • burn_batch(ids, amounts) is a function similar to the burn function, but it removes several tokens at the same time;
  • balance_of(account, id) is a function that provides information about the amount of tokens the specified account has with the given id;
  • balance_of_batch(accounts, ids) is a function similar to the balance_of function, but it returns info for multiple accounts and tokens;
  • transfer_from(from, to, id, amount) is a function that allows to make a token transfer from token_id to a quantity amount;
  • batch_transfer_from(from, to, ids, amounts) is a function similar to the transfer_from function, but it allows the transfer of multiple tokens at once;
  • approve(account) - is a function that gives the account access to use tokens;
  • revoke_approval(account) - is a function that cancels an account's access to use tokens;
  • transform(id, amount, nfts) - is a function that converts user tokens into multiple non-fungible tokens.

The multiple token program contains the following information:

multi-token/src/lib.rs
pub struct SimpleMtk {
pub tokens: MtkData,
pub creator: ActorId,
pub supply: HashMap<TokenId, u128>,
}

where the MtkData are defined as follows:

multi-token/io/src/lib.rs
pub struct MtkData {
pub name: String,
pub symbol: String,
pub base_uri: String,
pub balances: HashMap<TokenId, HashMap<ActorId, u128>>,
pub approvals: HashMap<ActorId, HashSet<ActorId>>,
pub token_metadata: HashMap<TokenId, TokenMetadata>,
pub owners: HashMap<TokenId, ActorId>,
}
  • name - multitoken name
  • symbol - multitoken symbol
  • base_uri - multitoken base URI
  • balances - stores the ownership information of all tokens
  • approvals - contains information about the approvals that have been made
  • token_metadata - token metadata relative to their id (only non-fungible tokens)
  • owners - owner of non-fungible tokens according to id
  • creator - creator of the multitoken collection
  • supply - counting the amount of tokens issued

Initialization

To initialize a program, it needs to be passed name, symbol and base_uri information:

multi-token/io/src/lib.rs
pub struct InitMtk {
pub name: String,
pub symbol: String,
pub base_uri: String,
}

Action

multi-token/io/src/lib.rs
pub enum MtkAction {
Mint {
id: TokenId,
amount: u128,
token_metadata: Option<TokenMetadata>,
},
Burn {
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,
},
Transform {
id: TokenId,
amount: u128,
nfts: Vec<BurnToNFT>,
},
}

Event

multi-token/io/src/lib.rs
pub enum MtkEvent {
Transfer {
from: ActorId,
to: ActorId,
ids: Vec<TokenId>,
amounts: Vec<u128>,
},
BalanceOf(Vec<BalanceReply>),
Approval {
from: ActorId,
to: ActorId,
},
RevokeApproval {
from: ActorId,
to: ActorId,
},
}

Program implementation

multi-token/src/lib.rs
#[no_mangle]
extern fn handle() {
let action: MtkAction = msg::load().expect("Failed to decode `MtkAction` message.");
let multi_token = unsafe { CONTRACT.as_mut().expect("`SimpleMtk` is not initialized.") };

let reply = match action {
MtkAction::Mint {
id,
amount,
token_metadata,
} => multi_token.mint(&msg::source(), vec![id], vec![amount], vec![token_metadata]),
MtkAction::Burn { id, amount } => multi_token.burn(vec![id], vec![amount]),
MtkAction::BalanceOf { account, id } => multi_token.balance_of(vec![account], vec![id]),
MtkAction::BalanceOfBatch { accounts, ids } => multi_token.balance_of(accounts, ids),
MtkAction::MintBatch {
ids,
amounts,
tokens_metadata,
} => multi_token.mint(&msg::source(), ids, amounts, tokens_metadata),
MtkAction::TransferFrom {
from,
to,
id,
amount,
} => multi_token.transfer_from(&from, &to, vec![id], vec![amount]),
MtkAction::BatchTransferFrom {
from,
to,
ids,
amounts,
} => multi_token.transfer_from(&from, &to, ids, amounts),
MtkAction::BurnBatch { ids, amounts } => multi_token.burn(ids, amounts),
MtkAction::Approve { account } => multi_token.approve(&account),
MtkAction::RevokeApproval { account } => multi_token.revoke_approval(&account),
MtkAction::Transform { id, amount, nfts } => multi_token.transform(id, amount, nfts),
};
msg::reply(reply, 0).expect("Failed to encode or reply with `Result<MtkEvent, MtkError>`.");
}

Mint

Requirements for a successful mint:

  • Сan't be mined from a zero-address account
  • Token ids must be unique
  • Length ids, amounts and meta must be the same
  • Сan't mint an nft that has already been created
  • If a fungible token is minted, it should not have a metadata
multi-token/src/lib.rs
fn mint(
&mut self,
account: &ActorId,
ids: Vec<TokenId>,
amounts: Vec<u128>,
meta: Vec<Option<TokenMetadata>>,
) -> Result<MtkEvent, MtkError> {
if *account == ActorId::zero() {
return Err(MtkError::ZeroAddress);
}

if ids.len() != amounts.len() || ids.len() != meta.len() {
return Err(MtkError::LengthMismatch);
}

let unique_ids: HashSet<_> = ids.clone().into_iter().collect();

if ids.len() != unique_ids.len() {
return Err(MtkError::IdIsNotUnique);
}

ids.iter().enumerate().try_for_each(|(i, id)| {
if self.tokens.token_metadata.contains_key(id) {
return Err(MtkError::TokenAlreadyExists);
} else if let Some(_token_meta) = &meta[i] {
if amounts[i] > 1 {
return Err(MtkError::MintMetadataToFungibleToken);
}
}
Ok(())
})?;

for (i, meta_item) in meta.into_iter().enumerate() {
self.mint_impl(account, &ids[i], amounts[i], meta_item)?;
}
for (id, amount) in ids.iter().zip(amounts.iter()) {
self.supply
.entry(*id)
.and_modify(|quantity| {
*quantity = quantity.saturating_add(*amount);
})
.or_insert(*amount);
}

Ok(MtkEvent::Transfer {
from: ActorId::zero(),
to: *account,
ids,
amounts,
})
}

fn mint_impl(
&mut self,
account: &ActorId,
id: &TokenId,
amount: u128,
meta: Option<TokenMetadata>,
) -> Result<(), MtkError> {
if let Some(metadata) = meta {
self.tokens.token_metadata.insert(*id, metadata);
// since we have metadata = means we have an nft, so add it to the owners
self.tokens.owners.insert(*id, *account);
}
let prev_balance = self.get_balance(account, id);
self.set_balance(account, id, prev_balance.saturating_add(amount));
Ok(())
}

Burn

multi-token/src/lib.rs
fn burn(&mut self, ids: Vec<TokenId>, amounts: Vec<u128>) -> Result<MtkEvent, MtkError> {
if ids.len() != amounts.len() {
return Err(MtkError::LengthMismatch);
}

let msg_src = &msg::source();
ids.iter()
.zip(amounts.clone())
.try_for_each(|(id, amount)| {
if self.tokens.token_metadata.contains_key(id) && amount > 1 {
return Err(MtkError::AmountGreaterThanOneForNft);
}
self.check_opportunity_burn(msg_src, id, amount)
})?;

ids.iter()
.enumerate()
.for_each(|(i, id)| self.burn_impl(msg_src, id, amounts[i]));

for (id, amount) in ids.iter().zip(amounts.iter()) {
let quantity = self.supply.get_mut(id).ok_or(MtkError::WrongId)?;
*quantity = quantity.saturating_sub(*amount);
}

Ok(MtkEvent::Transfer {
from: *msg_src,
to: ActorId::zero(),
ids,
amounts,
})
}

fn burn_impl(&mut self, msg_source: &ActorId, id: &TokenId, amount: u128) {
self.tokens.owners.remove(id);
self.set_balance(
msg_source,
id,
self.get_balance(msg_source, id).saturating_sub(amount),
);
}
multi-token/src/lib.rs
fn balance_of(&self, accounts: Vec<ActorId>, ids: Vec<TokenId>) -> Result<MtkEvent, MtkError> {
if accounts.len() != ids.len() {
return Err(MtkError::LengthMismatch);
}

let res = ids
.iter()
.zip(accounts)
.map(|(id, account)| BalanceReply {
account,
id: *id,
amount: self.get_balance(&account, id),
})
.collect();

Ok(MtkEvent::BalanceOf(res))
}

Transfer

Requirements for a successful transfer:

  • The address of the sender and the recipient cannot be the same
  • The sender is not the owner or approved account
  • Can't make a transfer to a zero address
  • Ids and amounts must have the same length
  • The user's balance must contain the required number of tokens for the transfer
multi-token/src/lib.rs
fn transfer_from(
&mut self,
from: &ActorId,
to: &ActorId,
ids: Vec<TokenId>,
amounts: Vec<u128>,
) -> Result<MtkEvent, MtkError> {
let msg_src = msg::source();
if from == to {
return Err(MtkError::SenderAndRecipientAddressesAreSame);
}

if from != &msg_src && !self.is_approved(from, &msg_src) {
return Err(MtkError::CallerIsNotOwnerOrApproved);
}

if to == &ActorId::zero() {
return Err(MtkError::ZeroAddress);
}

if ids.len() != amounts.len() {
return Err(MtkError::LengthMismatch);
}

for (id, amount) in ids.iter().zip(amounts.clone()) {
self.check_opportunity_transfer(from, id, amount)?;
}

for (i, id) in ids.iter().enumerate() {
self.transfer_from_impl(from, to, id, amounts[i])?;
}

Ok(MtkEvent::Transfer {
from: *from,
to: *to,
ids,
amounts,
})
}

fn transfer_from_impl(
&mut self,
from: &ActorId,
to: &ActorId,
id: &TokenId,
amount: u128,
) -> Result<(), MtkError> {
let from_balance = self.get_balance(from, id);
self.set_balance(from, id, from_balance.saturating_sub(amount));
let to_balance = self.get_balance(to, id);
self.set_balance(to, id, to_balance.saturating_add(amount));
Ok(())
}

Approve

multi-token/src/lib.rs
fn approve(&mut self, to: &ActorId) -> Result<MtkEvent, MtkError> {
if to == &ActorId::zero() {
return Err(MtkError::ZeroAddress);
}
let msg_src = &msg::source();
self.tokens
.approvals
.entry(*msg_src)
.and_modify(|approvals| {
approvals.insert(*to);
})
.or_insert_with(|| HashSet::from([*to]));

Ok(MtkEvent::Approval {
from: *msg_src,
to: *to,
})
}

Revoke approval

multi-token/src/lib.rs
fn revoke_approval(&mut self, to: &ActorId) -> Result<MtkEvent, MtkError> {
let msg_src = &msg::source();

let approvals = self.tokens.approvals.get_mut(msg_src).ok_or(MtkError::NoApprovals)?;
if !approvals.remove(to) {
return Err(MtkError::ThereIsNoThisApproval);
}

Ok(MtkEvent::RevokeApproval {
from: *msg_src,
to: *to,
})
}

Transform

multi-token/src/lib.rs
fn transform(
&mut self,
id: TokenId,
amount: u128,
nfts: Vec<BurnToNFT>,
) -> Result<MtkEvent, MtkError> {
// pre-checks
let mut nft_count = 0;
for info in &nfts {
nft_count += info.nfts_ids.len();
}
if amount as usize != nft_count {
return Err(MtkError::IncorrectData);
}

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

for burn_info in nfts.iter() {
if burn_info.account.is_zero() {
return Err(MtkError::ZeroAddress);
}
if burn_info.nfts_ids.len() != burn_info.nfts_metadata.len() {
return Err(MtkError::LengthMismatch);
}
}

let mut ids = vec![];
for burn_info in nfts {
burn_info
.nfts_metadata
.into_iter()
.zip(burn_info.nfts_ids.iter())
.try_for_each(|(meta, &id)| {
self.mint_impl(&burn_info.account, &id, NFT_COUNT, meta)
})?;

ids.extend_from_slice(&burn_info.nfts_ids);
}

let quantity = self.supply.get_mut(&id).ok_or(MtkError::WrongId)?;
*quantity = quantity.saturating_sub(amount);

Ok(MtkEvent::Transfer {
from: ActorId::zero(),
to: ActorId::zero(),
ids,
amounts: vec![NFT_COUNT; amount as usize],
})
}

Program metadata and state

Metadata interface description:

multi-token/io/src/lib.rs
pub struct MultitokenMetadata;

impl Metadata for MultitokenMetadata {
type Init = In<InitMtk>;
type Handle = InOut<MtkAction, Result<MtkEvent, MtkError>>;
type Others = ();
type Reply = ();
type Signal = ();
type State = Out<State>;
}

To display the program state information, the state() function is used:

multi-token/src/lib.rs
#[no_mangle]
extern fn state() {
let contract = unsafe { CONTRACT.take().expect("Unexpected error in taking state") };
msg::reply::<State>(contract.into(), 0).expect(
"Failed to encode or reply with `<ContractMetadata as Metadata>::State` from `state()`",
);
}

Conclusion

The Multiple Token program source code is available on Github.

See also an example of the program testing implementation based on gtest and gclient: gear-foundation/dapps/contracts/multi-token/tests.

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