Security Practices in Move Development (2): Aptos Coin

By BlockSec

In the previous article, we briefly introduce how to develop a Hello World program on the Aptos network. From now on, we will delve a little more deeply into the development of DeFi applications and their security concerns. As always, we’d like to start with some basic but important concepts. In this article, we will focus on Aptos coin (i.e., the fungible token in Aptos [1]), including its development and management, and interaction.

TL;DR

This article will tell you:

  • what is Aptos Coin?
  • how to create and manage your coin?
  • how to interact with your coin?

0x1. About Aptos Coin

As the atoms of DeFi, tokens (or coins) have been widely used in the blockchain ecosystems. They can be used to represent many kinds of stuff, including electronic currency, staking shares, and voting power for organizational management. To some extent, the daily activity of DeFi can simply be regarded as a huge volume of token flows across the blockchain systems.

Ethereum has developed a set of standards for tokens. The most well-known one is called ERC20, which specifies interfaces a standard ERC20 tokens need to comply with. ERC20 is a fungible token standard, while there also exist non-fungible token standards, such as ERC721.

Similar to other blockchain systems, Aptos also has its token standard [2] which defines how digital assets are created and used on their respective blockchains. Specifically, in Aptos, the fungible token is called coin [1], while the non-fungible token (i.e., NFT) is named token [3]. In the following, we will discuss the way to create, manage and interact with an Aptos coin.

0x2. Create and Manage Your First Coin

Aptos provides an official standard module (similar to ERC20): coin.move. By calling the API of this module, any user can easily create their own coin. Besides, coin.move also provides the permission mechanism to manage coins, which is important and useful to build complicated DeFi applications. In the following, we will demonstrate how to create a coin based on this module.

As mentioned in the previous article, you may create a project by typing the following command:

aptos move init --name my_coin

Then you need to create a new Move file under the ``sources’’ folder. Now let’s fill it with the following sample code, which defines a module named bsc to create and manage a standard coin named BSC.

module BlockSec::bsc{
use aptos_framework::coin;
use aptos_framework::event;
use aptos_framework::account;
use aptos_std::type_info;
use std::string::{utf8, String};
use std::signer;


struct BSC{}

struct CapStore has key{
mint_cap: coin::MintCapability<BSC>,
freeze_cap: coin::FreezeCapability<BSC>,
burn_cap: coin::BurnCapability<BSC>
}

struct BSCEventStore has key{
event_handle: event::EventHandle<String>,
}

fun init_module(account: &signer){
let (burn_cap, freeze_cap, mint_cap) = coin::initialize<BSC>(account, utf8(b"BSC"), utf8(b"BSC"), 6, true);
move_to(account, CapStore{mint_cap: mint_cap, freeze_cap: freeze_cap, burn_cap: burn_cap});
}

public entry fun register(account: &signer){
let address_ = signer::address_of(account);
if(!coin::is_account_registered<BSC>(address_)){
coin::register<BSC>(account);
};
if(!exists<BSCEventStore>(address_)){
move_to(account, BSCEventStore{event_handle: account::new_event_handle(account)});
};
}

fun emit_event(account: address, msg: String) acquires BSCEventStore{
event::emit_event<String>(&mut borrow_global_mut<BSCEventStore>(account).event_handle, msg);
}

public entry fun mint_coin(cap_owner: &signer, to_address: address, amount: u64) acquires CapStore, BSCEventStore{
let mint_cap = &borrow_global<CapStore>(signer::address_of(cap_owner)).mint_cap;
let mint_coin = coin::mint<BSC>(amount, mint_cap);
coin::deposit<BSC>(to_address, mint_coin);
emit_event(to_address, utf8(b"minted BSC"));
}

public entry fun burn_coin(account: &signer, amount: u64) acquires CapStore, BSCEventStore{
let owner_address = type_info::account_address(&type_info::type_of<BSC>());
let burn_cap = &borrow_global<CapStore>(owner_address).burn_cap;
let burn_coin = coin::withdraw<BSC>(account, amount);
coin::burn<BSC>(burn_coin, burn_cap);
emit_event(signer::address_of(account), utf8(b"burned BSC"));
}

public entry fun freeze_self(account: &signer) acquires CapStore, BSCEventStore{
let owner_address = type_info::account_address(&type_info::type_of<BSC>());
let freeze_cap = &borrow_global<CapStore>(owner_address).freeze_cap;
let freeze_address = signer::address_of(account);
coin::freeze_coin_store<BSC>(freeze_address, freeze_cap);
emit_event(freeze_address, utf8(b"freezed self"));
}

public entry fun emergency_freeze(cap_owner: &signer, freeze_address: address) acquires CapStore, BSCEventStore{
let owner_address = signer::address_of(cap_owner);
let freeze_cap = &borrow_global<CapStore>(owner_address).freeze_cap;
coin::freeze_coin_store<BSC>(freeze_address, freeze_cap);
emit_event(freeze_address, utf8(b"emergency freezed"));
}

public entry fun unfreeze(cap_owner: &signer, unfreeze_address: address) acquires CapStore, BSCEventStore{
let owner_address = signer::address_of(cap_owner);
let freeze_cap = &borrow_global<CapStore>(owner_address).freeze_cap;
coin::unfreeze_coin_store<BSC>(unfreeze_address, freeze_cap);
emit_event(unfreeze_address, utf8(b"unfreezed"));
}

}

0x2.1 Basic Design

First, look at the structure part. In total, three structures are defined.

  • The BSC struct, which is used as the unique identifier of the coin. As such, this coin can be uniquely determined through the BlockSec::bsc::BSC path.
  • The CapStore struct, which is used to store some capabilities obtained from the aptos_framework::coin module. These capabilities correspond to the permissions of some special operations and will be explained later.
  • The BSCEventStore struct, which is used for recording user events.

After those structures, there is an init_module function, which is used to initialize the module and will only be called once when the module is published on the chain. In this function, the module calls coin::initialize<BSC> to register the BlockSec::bsc::BSC as a unique identifier for a new coin.

public fun initialize<CoinType>(
account: &signer,
name: string::String,
symbol: string::String,
decimals: u8,
monitor_supply: bool,
): (BurnCapability<CoinType>, FreezeCapability<CoinType>, MintCapability<CoinType>) {
initialize_internal(account, name, symbol, decimals, monitor_supply, false)
}

/// Capability required to mint coins.
struct MintCapability<phantom CoinType> has copy, store {}

/// Capability required to freeze a coin store.
struct FreezeCapability<phantom CoinType> has copy, store {}

/// Capability required to burn coins.
struct BurnCapability<phantom CoinType> has copy, store {}

After the registration, all generic functions that take BlockSec::bsc::BSC type in aptos_framework::coin will operate on this coin. This registration process will return three capabilities, i.e., MintCapability, FreezeCapability and BurnCapability. These capabilities are required for minting coins, freezing user accounts, and burning coins, respectively. To some extent, the functionality of such a capability struct is similar to a key, which is used to open a lock to a specific permission. If someone has the key, he can get the corresponding permission. Here we store these capabilities in the CapStore struct (owned by the administrator/publisher of this module) for later usage.

Meanwhile, during the registration process, a CoinInfo struct will be stored under the administrator address to record relevant information:

/// Information about a specific coin type. Stored on the creator of the coin's account.
struct CoinInfo<phantom CoinType> has key {
name: string::String,
/// Symbol of the coin, usually a shorter version of the name.
/// For example, Singapore Dollar is SGD.
symbol: string::String,
/// Number of decimals used to get its user representation.
/// For example, if `decimals` equals `2`, a balance of `505` coins should
/// be displayed to a user as `5.05` (`505 / 10 ** 2`).
decimals: u8,
/// Amount of this coin type in existence.
supply: Option<OptionalAggregator>,
}

After the invocation of the init_module function, your coin has been registered on the chain. However, no one can use this coin as there does not exist any circulation for the time being. To make the coin usable, some operations, including issuance, allocation, and destruction, need to be supported. These operations require the capabilities we obtained when registering the coin.

0x2.2 Coin Management

This coin is designed to obey the following rules:

  • Only the administrator (admin) can mint coins.
  • Users can burn their own coins at any time.
  • Users can freeze/unfreeze their accounts at any time.

Accordingly, we define five management functions, i.e., mint_coin, burn_coin, freeze_self, emergency_freeze and unfreeze. The first two functions are in charge of minting coins and burning coins, respectively; while the latter three are used to freeze and unfreeze accounts.

Minting Coins

In our module, the mint_coin function is used to mint coins. Because only admins can mint coins, we have to verify the corresponding capability in this function.

public entry fun mint_coin(cap_owner: &signer, to_address: address, amount: u64) acquires CapStore, BSCEventStore{
let mint_cap = &borrow_global<CapStore>(signer::address_of(cap_owner)).mint_cap;
let mint_coin = coin::mint<BSC>(amount, mint_cap);
coin::deposit<BSC>(to_address, mint_coin);
emit_event(to_address, utf8(b"minted BSC"));
}

This function requires three parameters:

  • cap_owner is of type &signer, i.e., the initiator of the transaction.
  • to_address indicates the address to which the minted coins will be deposited.
  • amount indicates the number of coins being minted.

It consists of three steps: acquiring the capability to mint coins, minting coins, and depositing coins.

First, at the beginning of the mint_coin function, the account address of the transaction initiator can be obtained through signer::address_of(cap_owner). After that, borrow_global<CapStore> is used to confirm whether the account owns CapStore to verify that it is the admin of the module. By doing so, we can guarantee that only the admin can mint coins, while other users will fail at this step.

Second, the mint_coin function will then invoke the mint function of the aptos_framework::coin module to mint coins.

public fun mint<CoinType>(
amount: u64,
_cap: &MintCapability<CoinType>,
): Coin<CoinType> acquires CoinInfo {
if (amount == 0) {
return zero<CoinType>()
};

let maybe_supply = &mut borrow_global_mut<CoinInfo<CoinType>>(coin_address<CoinType>()).supply;
if (option::is_some(maybe_supply)) {
let supply = option::borrow_mut(maybe_supply);
optional_aggregator::add(supply, (amount as u128));
};

Coin<CoinType> { value: amount }
}

Here MintCapability is required. Specifically, a parameter named _cap is required to be passed as a reference of MintCapability. Then the permission associated with the ```MintCapability``` capability is transferred accordingly as well. Although no explicit access control, the verification is enforced by the Move language.

Third, the mint_coin function will invoke the deposit function to deposit the minted coins to the specified to_address.

Tip#1: Access controls for privileged accounts can be verified by using a similar method.

Burning Coins

The procedure of burning coins is different from that of minting coins. Specifically, only the admin is allowed to call the mint_coin function, while any user can invoke the burn_coin function. To this end, the burn_coin function has to temporarily escalate the privilege, i.e., obtaining the BurnCapability capability, for these users.

public entry fun burn_coin(account: &signer, amount: u64) acquires CapStore, BSCEventStore{
let owner_address = type_info::account_address(&type_info::type_of<BSC>());
let burn_cap = &borrow_global<CapStore>(owner_address).burn_cap;
let burn_coin = coin::withdraw<BSC>(account, amount);
coin::burn<BSC>(burn_coin, burn_cap);
emit_event(signer::address_of(account), utf8(b"burned BSC"));
}

This function requires two parameters:

  • account is of type &signer, i.e., the initiator of the transaction.
  • amount indicates the number of coins being burnt.

It also consists of three steps: acquiring the capability to burn coins, withdrawing coins, and burning coins.

Obviously, the burn function of the aptos_framework::coin module requires the caller to pass in a reference to BurnCapability, but this capability is stored in the CapStore of the admin. As such, we have to allow ordinary users to obtain this capability to burn the coins they hold.

public fun burn<CoinType>(
coin: Coin<CoinType>,
_cap: &BurnCapability<CoinType>,
) acquires CoinInfo {
let Coin { value: amount } = coin;
assert!(amount > 0, error::invalid_argument(EZERO_COIN_AMOUNT));

let maybe_supply = &mut borrow_global_mut<CoinInfo<CoinType>>(coin_address<CoinType>()).supply;
if (option::is_some(maybe_supply)) {
let supply = option::borrow_mut(maybe_supply);
optional_aggregator::sub(supply, (amount as u128));
}
}

To achieve this goal, we may use the borrow_global operator provided by the Move language. It is used to read a particular data type from the immutable global storage of an account. By using this operator, a module can lend the capability owned by the admin to other users. Namely, the admin address is required to get the capability we want.

However, the transaction initiator of the burn_coin function is the user rather than the admin. Hence the admin address cannot be obtained through signer (like the mint_coin function). Fortunately, it can be obtained through aptos_std::type_info with BSC to get the module's address where this structure is defined. As the module was published under the admin address, we can further obtain the admin address accordingly, and finally get the BurnCapability capability.

Tip#2: The borrow_global operator can be used to temporarily obtain the capabilities of a module.

After getting the BurnCapability, the module can withdraw the coin of the specified amount from the user and burn the coin with that capability.

Freezing and Unfreezing Coin Accounts

Based on the above discussion, now we can easily go through the management of the coin accounts. Specifically, we provide the freeze_self function for users to freeze their coin accounts. Here we also provide the emergency_freeze function for emergency freezing, which can only be used by the admin. Furthermore, due to the existence of the emergency freezing mechanism, users should not be allowed to unfreeze themselves. So the unfreeze function also requires the admin to unfreeze the user accounts.

public entry fun freeze_self(account: &signer) acquires CapStore, BSCEventStore{
let owner_address = type_info::account_address(&type_info::type_of<BSC>());
let freeze_cap = &borrow_global<CapStore>(owner_address).freeze_cap;
let freeze_address = signer::address_of(account);
coin::freeze_coin_store<BSC>(freeze_address, freeze_cap);
emit_event(freeze_address, utf8(b"freezed self"));
}

public entry fun emergency_freeze(cap_owner: &signer, freeze_address: address) acquires CapStore, BSCEventStore{
let owner_address = signer::address_of(cap_owner);
let freeze_cap = &borrow_global<CapStore>(owner_address).freeze_cap;
coin::freeze_coin_store<BSC>(freeze_address, freeze_cap);
emit_event(freeze_address, utf8(b"emergency freezed"));
}

public entry fun unfreeze(cap_owner: &signer, unfreeze_address: address) acquires CapStore, BSCEventStore{
let owner_address = signer::address_of(cap_owner);
let freeze_cap = &borrow_global<CapStore>(owner_address).freeze_cap;
coin::unfreeze_coin_store<BSC>(unfreeze_address, freeze_cap);
emit_event(unfreeze_address, utf8(b"unfreezed"));
}

Tip#3: Be cautious when returning capabilities through functions! Malicious users who obtain these capabilities may abuse them to cause damages to the coin holders!

0x3. Interact With the Coin

Here we focus on the way to interact with the coin.

0x3.1 Register

You may have noticed that there exists a register function in the module:

public entry fun register(account: &signer){
let address_ = signer::address_of(account);
if(!coin::is_account_registered<BSC>(address_)){
coin::register<BSC>(account);
};
if(!exists<BSCEventStore>(address_)){
move_to(account, BSCEventStore{event_handle: account::new_event_handle(account)});
};
}

This function is used to help users register coin usage rights and event recorders. In order to use a certain coin, the aptos_framework::coin module stipulates that the user has to first explicitly register the right to use the coin through the aptos_framework::coin::register function.

public fun register<CoinType>(account: &signer) {
let account_addr = signer::address_of(account);
assert!(
!is_account_registered<CoinType>(account_addr),
error::already_exists(ECOIN_STORE_ALREADY_PUBLISHED),
);

account::register_coin<CoinType>(account_addr);
let coin_store = CoinStore<CoinType> {
coin: Coin { value: 0 },
frozen: false,
deposit_events: account::new_event_handle<DepositEvent>(account),
withdraw_events: account::new_event_handle<WithdrawEvent>(account),
};
move_to(account, coin_store);
}

Users can hold this coin normally only if they register this type of coin through this function. That is to say, if you do not wish to hold a particular coin, others cannot put this coin into your account without your consent. The registration actually puts a CoinStore struct (of the target coin type) into your account. This CoinStore struct contains a Coin struct to record your balance.

Tip#4: Unlike Ethereum tokens, an Aptos coin cannot be held and operated without the explicit registration of a user.

0x3.2 Transfer

Assuming you now have some BSC coins, then you can transfer these coins by invoking the transfer function of the aptos_framework::coin module.

public entry fun transfer<CoinType>(
from: &signer,
to: address,
amount: u64,
) acquires CoinStore {
let coin = withdraw<CoinType>(from, amount);
deposit(to, coin);
}

Note that this is an entry function provided by the coin module. The logic consists of invocations of two public functions, i.e., withdraw and deposit. The withdraw function requires the &signer permission, which is used to withdraw a certain amount of assets from your account into a coin. The deposit function can deposit a coin into any registered account of the coin. This function does not need extra permissions and will deposit the specified coins into the account address. Finally, the transferred coins will be automatically merged with the coins stored in the CoinStore struct of the target address.

Tip#5: After withdrawing, the assets in the coin are under the control of the current transfer function. This function can deliver these assets to the deposit function without acquiring extra permissions.

0x3.3 Split and Merge

Unlike Ethereum tokens, the circulation of coins cannot be updated by modifying the users’ balances. Instead, it can be achieved by withdrawing the Coin struct in the coin module. By doing so, users realize asset circulation by passing this struct to other modules. Since a struct can only be manipulated by the module that defines it, the coin module provides some interfaces to operate the Coin struct, including dividing coins into smaller units and merging multiple coins to meet the needs of different scenarios.

1. The extract function is used to split coins. It receives a Coin struct, extracts a part of the asset in it to generate a new Coin struct, and returns the new struct.

public fun extract<CoinType>(coin: &mut Coin<CoinType>, amount: u64): Coin<CoinType> {
assert!(coin.value >= amount, error::invalid_argument(EINSUFFICIENT_BALANCE));
coin.value = coin.value - amount;
Coin { value: amount }
}

2. The extract_all function is used to extract the entire value of the original Coin struct and deposit it into a new Coin struct. As a result, the value of the original Coin struct will become zero (aka zero_coin). The zero_coin struct can be destroyed by invoking the destroy_zero function.

public fun extract_all<CoinType>(coin: &mut Coin<CoinType>): Coin<CoinType> {
let total_value = coin.value;
coin.value = 0;
Coin { value: total_value }
}

public fun destroy_zero<CoinType>(zero_coin: Coin<CoinType>) {
let Coin { value } = zero_coin;
assert!(value == 0, error::invalid_argument(EDESTRUCTION_OF_NONZERO_TOKEN))
}

3. The merge function is used to merge coins. You can merge the value of two Coin structs, i.e., source_coin and dst_coin, into the dst_coin struct and destroy the source_coin struct.

public fun merge<CoinType>(dst_coin: &mut Coin<CoinType>, source_coin: Coin<CoinType>) {
spec {
assume dst_coin.value + source_coin.value <= MAX_U64;
};
dst_coin.value = dst_coin.value + source_coin.value;
let Coin { value: _ } = source_coin;
}

4. The zero function is used to generate a zero_coin struct.

public fun zero<CoinType>(): Coin<CoinType> {
Coin<CoinType> {
value: 0
}
}

0x4. Test the Coin

To quickly test this coin, you can first deploy it with the following command (don’t forget to set your publisher account address in Move.toml!)

$ aptos move publish --package-dir ./
Compiling, may take a little while to download git dependencies...
INCLUDING DEPENDENCY AptosFramework
INCLUDING DEPENDENCY AptosStdlib
INCLUDING DEPENDENCY MoveStdlib
BUILDING my_coin
package size 2751 bytes
Do you want to submit a transaction for a range of [868300 - 1302400] Octas at a gas unit price of 100 Octas? [yes/no] >
yes
{
"Result": {
"transaction_hash": "xxx",
...

In this way, the coin was successfully published on the chain, but it does not have any circulation. You need to register your account to receive the minted coins.

$ aptos move run --function-id default::bsc::register
Do you want to submit a transaction for a range of [153100 - 229600] Octas at a gas unit price of 100 Octas? [yes/no] >
yes
{
"Result": {
"transaction_hash": "xxx",
...

Note that Your-address in the second command needs to be replaced with your own address (see account in .aptos/config.yaml). Enter your address in the browser and click the Resources tab, you can see that this account now has 100 BSC coins.

If you want to transfer coins to another account, do not forget to let that account register to use the coin. Because the transfer function is a generic function, you need to specify the generic parameter as BSC and to_address, as follows:

$ aptos move run — function-id 0x1::coin::transfer — type-args BSC-module-address::bsc::BSC — args address:To_address u64:1

This command will invoke the transfer function of the coin module to transfer 1 BSC coin to To_address. Here 0x1::coin::transfer is the function id of the transfer function. Remember that the BlockSec::bsc::BSC is the identifier of your coin, the generic parameter must be specified to it. Besides, BSC-module-address should be replaced with the module publisher account address, which is assigned to BlockSec in Move.toml.

0x5. What’s Next

After understanding how to create, manage and interact with your own coin, we will demonstrate how to build the first DeFi cornerstone project: Automated Market Maker (AMM). More interesting topics related to the Move development and security practices will be covered. Stay tuned!

Reference

[1] https://aptos.dev/concepts/coin-and-token/aptos-coin/
[2] https://aptos.dev/concepts/coin-and-token/index
[3] https://aptos.dev/concepts/coin-and-token/aptos-token

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
BlockSec

The BlockSec Team focuses on the security of the blockchain ecosystem and the research of crypto hack monitoring and blocking, smart contract auditing.