Move開発におけるセキュリティプラクティス(2):Aptos Coin

Aptosの公式標準モジュールcoin.moveを使って、あなた自身のコインを簡単に作成・管理する方法を学ぶ。

Move開発におけるセキュリティプラクティス(2):Aptos Coin

前回の記事では、Aptosネットワーク上でのHello Worldプログラムの開発方法を簡単に紹介しました。今後は、DeFiアプリケーションの開発とそのセキュリティ上の懸念について、より深く掘り下げていきます。いつものように、基本的ながらも重要な概念から始めたいと思います。この記事では、Aptosコイン(つまり、Aptosの代替可能なトークン[1])の開発、管理、およびインタラクションに焦点を当てます。

TL;DR

この記事では、以下の点について説明します。

  • Aptosコインとは何か?
  • コインを作成・管理するには?
  • コインとインタラクションするには?

0x1. Aptosコインについて

DeFiの原子として、トークン(またはコイン)はブロックチェーンエコシステムで広く利用されています。これらは、電子通貨、ステーキングシェア、組織管理のための投票権など、さまざまなものを表すために使用できます。ある程度、DeFiの日常的な活動は、ブロックチェーンシステム間での大量のトークンフローとして単純に見なすことができます。

Ethereumはトークンの標準セットを開発しました。最もよく知られているのはERC20と呼ばれ、標準のERC20トークンが準拠する必要のあるインターフェースを指定しています。ERC20は代替可能なトークン標準ですが、ERC721のような非代替可能なトークン標準も存在します。

他のブロックチェーンシステムと同様に、Aptosにもトークン標準[2]があり、それぞれのブロックチェーン上でデジタル資産がどのように作成され使用されるかを定義しています。具体的には、Aptosでは代替可能なトークンはコインと呼ばれ、非代替可能なトークン(つまりNFT)はトークンと呼ばれます。以下では、Aptosコインの作成、管理、およびインタラクションの方法について説明します。

0x2. 最初のコインの作成と管理

Aptosは公式標準モジュール(ERC20に似ています)coin.moveを提供しています。このモジュールのAPIを呼び出すことで、どのユーザーでも簡単に独自のコインを作成できます。さらに、coin.moveはコインを管理するための権限メカニズムも提供しており、これは複雑なDeFiアプリケーションを構築するために重要で役立ちます。以下では、このモジュールに基づいてコインを作成する方法を実演します。

前回の記事で述べたように、以下のコマンドを入力してプロジェクトを作成できます。

aptos move init --name my_coin

次に、sourcesフォルダの下に新しいMoveファイルを作成する必要があります。ここで、BlockSec::bscという名前のモジュールを定義し、標準コイン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 基本設計

まず、構造体部分を見てみましょう。合計3つの構造体が定義されています。

  • BSC構造体:コインの一意の識別子として使用されます。そのため、このコインはBlockSec::bsc::BSCパスを通じて一意に特定できます。
  • CapStore構造体:aptos_framework::coinモジュールから取得した一部のケーパビリティ(機能)を格納するために使用されます。これらのケーパビリティは、一部の特別な操作の権限に対応しており、後で説明します。
  • BSCEventStore構造体:ユーザーイベントの記録に使用されます。

これらの構造体の後には、init_module関数があり、モジュールの初期化に使用され、チェーン上にモジュールが発行されるときに一度だけ呼び出されます。この関数では、モジュールはcoin::initialize<BSC>を呼び出して、BlockSec::bsc::BSCを新しいコインの一意の識別子として登録します。

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 {}

登録後、aptos_framework::coinモジュールでBlockSec::bsc::BSC型を取るすべての汎用関数が、このコインを操作します。この登録プロセスは、それぞれコインのミント、ユーザーアカウントのフリーズ、コインのバーンに必要な3つのケーパビリティ、つまりMintCapabilityFreezeCapabilityBurnCapabilityを返します。ある程度、これらのケーパビリティ構造体の機能は、特定の権限へのロックを解除するために使用されるキーに似ています。キーを持っている人は、対応する権限を取得できます。ここでは、これらのケーパビリティをCapStore構造体(モジュールの管理者/発行者が所有)に格納して、後で使用できるようにします。

同時に、登録プロセス中に、管理者アドレスの下にCoinInfo構造体が格納され、関連情報が記録されます。

/// 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>,
}

init_module関数の呼び出し後、コインはチェーンに登録されました。しかし、現時点では流通がないため、誰もこのコインを使用できません。コインを使用可能にするには、発行、割り当て、破棄などの操作をサポートする必要があります。これらの操作には、コイン登録時に取得したケーパビリティが必要です。

0x2.2 コイン管理

このコインは、以下のルールに従うように設計されています。

  • 管理者(admin)のみがコインをミントできます。
  • ユーザーはいつでも自分のコインをバーンできます。
  • ユーザーはいつでも自分のアカウントをフリーズ/アンフリーズできます。

これに従って、mint_coinburn_coinfreeze_selfemergency_freezeunfreezeの5つの管理関数を定義しました。最初の2つの関数はそれぞれコインのミントとバーンを担当し、残りの3つはアカウントのフリーズとアンフリーズに使用されます。

コインのミント

私たちのモジュールでは、mint_coin関数がコインのミントに使用されます。管理者のみがコインをミントできるため、この関数で対応するケーパビリティを確認する必要があります。

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"));
}

この関数は3つのパラメータを必要とします。

  • cap_owner:トランザクションのイニシエータである&signer型です。
  • to_address:ミントされたコインがデポジットされるアドレスを指定します。
  • amount:ミントされるコインの数量を指定します。

これは、ケーパビリティの取得、コインのミント、コインのデポジットの3つのステップで構成されます。

まず、mint_coin関数の冒頭で、トランザクションイニシエータのアカウントアドレスはsigner::address_of(cap_owner)を通じて取得できます。その後、borrow_global<CapStore>を使用して、アカウントがCapStoreを所有しているか確認し、モジュールの管理者であることを検証します。これにより、管理者のみがコインをミントでき、他のユーザーはこのステップで失敗することを保証できます。

次に、mint_coin関数はaptos_framework::coinモジュールのmint関数を呼び出してコインをミントします。

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 }
}

ここでMintCapabilityが必要です。具体的には、_capという名前のパラメータがMintCapabilityへの参照として渡される必要があります。その後、MintCapabilityケーパビリティに関連付けられた権限も同様に移譲されます。明示的なアクセス制御はありませんが、検証はMove言語によって強制されます。

第三に、mint_coin関数はdeposit関数を呼び出して、ミントされたコインを指定されたto_addressにデポジットします。

Tip#1: 特権アカウントへのアクセス制御は、同様の方法で検証できます。

コインのバーン

コインのバーンプロセスは、コインのミントとは異なります。具体的には、管理者のみがmint_coin関数を呼び出すことができますが、どのユーザーでもburn_coin関数を呼び出すことができます。この目的のために、burn_coin関数は、これらのユーザーに一時的に権限をエスカレートする必要があります。つまり、BurnCapabilityケーパビリティを取得します。

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"));
}

この関数は2つのパラメータを必要とします。

  • account:トランザクションのイニシエータである&signer型です。
  • amount:バーンされるコインの数量を指定します。

これは、ケーパビリティの取得、コインの引き出し、コインのバーンの3つのステップで構成されます。

明らかに、aptos_framework::coinモジュールのburn関数は、呼び出し元にBurnCapabilityへの参照を渡すことを要求しますが、このケーパビリティは管理者のCapStoreに格納されています。そのため、一般ユーザーがこのケーパビリティを取得して、保有するコインをバーンできるようにする必要があります。

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));
    }
}

この目標を達成するために、Move言語で提供されているborrow_global演算子を使用できます。これは、アカウントの不変のグローバルストレージから特定のデータ型を読み取るために使用されます。この演算子を使用することで、モジュールは管理者が所有するケーパビリティを他のユーザーに**貸し出す**ことができます。つまり、ケーパビリティを取得するには管理者アドレスが必要です。

しかし、burn_coin関数のトランザクションイニシエータは管理者ではなくユーザーです。したがって、signermint_coin関数のように)を通じて管理者アドレスを取得することはできません。幸いなことに、aptos_std::type_infoBSCとともに使用して、この構造体が定義されているモジュールのアドレスを取得できます。モジュールは管理者アドレスの下に発行されたため、そこから管理者アドレスをさらに取得し、最終的にBurnCapabilityケーパビリティを取得できます。

Tip#2: _borrow_global_ 演算子を使用して、モジュールのケーパビリティを一時的に取得できます。

BurnCapabilityを取得した後、モジュールは指定された量のコインをユーザーから引き出し、そのケーパビリティでコインをバーンできます。

コインアカウントのフリーズとアンフリーズ

上記の議論に基づき、コインアカウントの管理を簡単に実行できます。具体的には、ユーザーが自分のコインアカウントをフリーズできるようにfreeze_self関数を提供しています。また、緊急フリーズのためのemergency_freeze関数も提供しており、これは管理者のみが使用できます。さらに、緊急フリーズメカニズムが存在するため、ユーザーは自分でアンフリーズできないようにする必要があります。したがって、unfreeze関数も管理者によるユーザーアカウントのアンフリーズを必要とします。

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: 関数を通じてケーパビリティを返す際には注意してください!これらのケーパビリティを取得した悪意のあるユーザーは、それらを悪用してコイン保有者に損害を与える可能性があります!

0x3. コインとのインタラクション

ここでは、コインとのインタラクションの方法に焦点を当てます。

0x3.1 登録

モジュールにregister関数が存在することに気づいたかもしれません。

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)});
    };
}

この関数は、ユーザーがコインの使用権とイベントレコーダーを登録するのに役立ちます。特定のコインを使用するために、aptos_framework::coinモジュールは、ユーザーがまずaptos_framework::coin::register関数を通じてコインの使用権を明示的に登録する必要があると規定しています。

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);
}

ユーザーは、この関数を通じてこのタイプのコインを登録した場合にのみ、このコインを正常に保有できます。つまり、特定のコインを保有したくない場合、他者はあなたの同意なしにそのコインをあなたの口座に入れることはできません。登録は実際には、あなたの口座にCoinStore構造体(対象コインタイプのもの)を配置します。このCoinStore構造体には、あなたの残高を記録するCoin構造体が含まれています。

Tip#4: Ethereumトークンとは異なり、Aptosコインはユーザーの明示的な登録なしには保有および操作できません。

0x3.2 送金

BSCコインを保有していると仮定すると、aptos_framework::coinモジュールのtransfer関数を呼び出してこれらのコインを転送できます。

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

これはcoinモジュールによって提供されるエントリー関数であることに注意してください。ロジックは、withdrawdepositの2つの公開関数の呼び出しで構成されます。withdraw関数は、&signer権限を必要とし、アカウントから一定量の資産を引き出してコインにします。deposit関数は、登録済みのどのコインアカウントにもコインをデポジットできます。この関数は追加の権限を必要とせず、指定されたコインをアカウントアドレスにデポジットします。最後に、転送されたコインは、ターゲットアドレスのCoinStore構造体に格納されているコインと自動的にマージされます。

Tip#5: 引き出した後、コイン内の資産は現在のtransfer関数の制御下にあります。この関数は、追加の権限を取得することなく、これらの資産をdeposit関数に配信できます。

0x3.3 分割とマージ

Ethereumトークンとは異なり、コインの流通はユーザーの残高を変更することによって更新されるのではなく、coinモジュールのCoin構造体を引き出すことによって実現できます。これにより、ユーザーはこの構造体を他のモジュールに渡すことで資産の流通を実現します。構造体はそれを定義したモジュールによってのみ操作できるため、coinモジュールは、コインをより小さな単位に分割したり、複数のコインをマージしたりするためのインターフェースを提供して、さまざまなシナリオのニーズを満たします。

  1. extract関数はコインを分割するために使用されます。Coin構造体を受け取り、その資産の一部を抽出して新しいCoin構造体を生成し、新しい構造体を返します。
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 }
}
  1. extract_all関数は、元のCoin構造体の全額を抽出して新しいCoin構造体にデポジットするために使用されます。その結果、元のCoin構造体の値はゼロ(つまりzero_coin)になります。zero_coin構造体は、destroy_zero関数を呼び出すことで破棄できます。
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))
}
  1. merge関数はコインをマージするために使用されます。2つのCoin構造体、つまりsource_coindst_coinの値をdst_coin構造体にマージし、source_coin構造体を破棄できます。
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;
}
  1. zero関数はzero_coin構造体を生成するために使用されます。
public fun zero<CoinType>(): Coin<CoinType> {
    Coin<CoinType> {
        value: 0
    }
}

0x4. コインのテスト

このコインを迅速にテストするには、まず以下のコマンドでデプロイします(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",
    ...

これにより、コインはチェーン上に正常に発行されましたが、流通はありません。ミントされたコインを受け取るには、アカウントを登録する必要があります。

$ 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",
    ...

注意:2番目のコマンドの**Your-address**は、あなた自身の(.aptos/config.yamlaccountを参照)アドレスに置き換える必要があります。ブラウザ[https://explorer.aptoslabs.com/?network=local]にアドレスを入力し、「Resources」タブをクリックすると、このアカウントに100 BSCコインがあることがわかります。

コインを別のアカウントに転送したい場合は、そのアカウントにコインの使用を登録してもらうことを忘れないでください。transfer関数は汎用関数であるため、ジェネリックパラメータをBSCto_addressに指定する必要があります。例:

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

このコマンドは、コインモジュールのtransfer関数を呼び出して、1 BSCコインを**To_addressに転送します。ここでは、0x1::coin::transfertransfer関数の関数IDです。BlockSec::bsc::BSCがコインの識別子であることを覚えておいてください。ジェネリックパラメータはそれに指定する必要があります。さらに、BSC-module-addressは、Move.tomlBlockSec**に割り当てられたモジュールの発行者アカウントアドレスに置き換える必要があります。

0x5. 次のステップ

独自のコインの作成、管理、およびインタラクション方法を理解した後、最初のDeFiの基盤プロジェクトである自動マーケットメーカー(AMM)の構築方法を実演します。Move開発とセキュリティプラクティスに関連するさらに興味深いトピックが取り上げられます。続報にご期待ください!

参考文献

[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

Sign up for the latest updates