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 theBlockSec::bsc::BSC
path. - The
CapStore
struct, which is used to store some capabilities obtained from theaptos_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 thedeposit
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