📖

Aptos上にNFTマーケットプレイスを作ってみよう!!

2024/04/07に公開

はじめに

皆さん、こんにちは!

今回はパブリックブロックチェーンの一つであるAptosをテーマにした記事になります!

2024年現在 ハッカソンプラットフォームAkindoとAptosのチームがタッグ組んでWaveHackというグラントプログラムを実施中です!

グラントには2つの部門があるのですが、今回はそのうちの一つである

Create Aptos Move Contents

部門への応募も兼ねた記事になっています!!

対象となっているAptosのドキュメントを翻訳するだけでグラントを取得できる可能性があり、非エンジニアでもグラントを獲得できる非常に貴重な機会となっていますので皆さんもぜひ挑戦してみてくださいね!!

詳細は下記サイトにて紹介されています!

https://app.akindo.io/wave-hacks/Nmqgo639ai9ZQMB1

https://app.akindo.io/wave-hacks/Z47L4rwQmI774vRpr

https://lu.ma/aptos-wavehack

今回翻訳に挑戦するドキュメント

Aptosチームが出しているラーニングサイトの以下の記事の翻訳に挑戦します!!

https://learn.aptoslabs.com/example/nft-marketplace

Aptos上でたまごっちライクなキャラクターを売買できるNFTマーケットプレイスが構築できるようです!!

https://marketplace-example-rho.vercel.app/

前回の記事が見てみたいという方はぜひ下記もチェックしてみてください!!

https://zenn.dev/mashharuki/articles/f6deb29bb67cd3

今回お手本となるGitHubのリポジトリは下記です!

https://github.com/aptos-labs/marketplace-example

私は上記のリポジトリをベースにコメントなどを追記しています!!

https://github.com/mashharuki/marketplace-example

この学習コンテンツでは次のことが学べます!!

この学習コンテンツで開発するNFTマーケットプレイスの概要

Move言語によるデータストレージの考え方

データを永続的に保管する方法として以下の3つの方法があります。

  • アカウント内のリソース

    アカウント内のリソースでは、アカウントやアドレスに直接結びついたMoveのストレージ機能を利用できます。アカウントタイプごとに一意に保存され、保存にはmove_to、取得にはborrow_globalといった特定の関数でアクセスできます。

    構造体は、アカウントにステートをアタッチする簡単な方法を提供しますが、ガスコストと単一リソースのサイズ制約により、ストレージに制限があります。これらの制限を回避するために、構造体には他の構造体やベクトルを含めることができますが、その代わり複雑になります。

  • テーブル

    Moveのテーブルでは、柔軟なキーとバリューを管理できます。同じ構造体タイプの複数のインスタンスを衝突リスクなしに使用できるため、リソースとは異なります。テーブルは、大規模なデータセットへの効率的で決定論的なアクセスを必要とするアプリケーションで特に役立ちます。

    Aptosでは、スケーラブルなデータ処理を実現するBigVectorやSmartVector、凝縮されたストレージを実現するSmartTableなどの構造体でテーブルを強化し、グローバル・ストレージのスペースとアクセス効率を最適化しています。

  • オブジェクト

    オブジェクトは、データ・ストレージに対する微妙なアプローチを提供し、リソースをアカウントや他のオブジェクトによって所有・管理できるようにすることで、オンチェーン・データの構成能力を高めます。

    このモデルは、1つのアカウントや契約の下で複数のリソースインスタンスを管理することを容易にし、オブジェクトの所有権と転送性を活用することで、さまざまなドメインにわたる複雑なアプリケーションの開発を合理化します。

今回実装するスマートコントラクトのデータ構造のポイント

NFTマーケットプレイスのデータ構造を設計する際には、出品と取引を自律的に管理する方法が必要です。

このプロセスを促進するためには、価格、売り手の詳細、トークンのアドレスといった重要な情報をオンチェーンに保存することが必須です。

例えば下記のような点ですね。

  1. 売り手ごとに複数のリスティングを保存する

    売り手は異なるトークンについて複数のリスティングを作成できるようにする。

    例えば、Alice は複数の Aptogotchi トークンをリストし、それぞれに固有のリスト情報を持つかもしれません。

    そのためには、一つの売り手に関連する、同じ型の複数のインスタンスをサポートするデータ構造が必要です。

  2. リスティングのライフサイクルの管理

    リスティングにはライフサイクルがあり、トークンの販売で終了します。

    購入後、マーケットプレイスの整合性を維持するために、リスティングデータを効率的にクリーンアップして削除することが重要になってきます。

これらの要件を実現させるために、アカウントごとにリソースのインスタンスを1つだけ保存するという制限を克服するために、リストデータをオブジェクトとして保存します。

この方法は、売り手ごとに複数の出品を可能にするだけでなく、デジタル資産標準を実装するためにオブジェクトを使用する時と同じです。

Aptosのオブジェクトモデルについてさらに知りたいという方は下記リンクを参考にしてみてください。

https://aptos.dev/standards/aptos-object/

オブジェクトリソースの定義

Moveでは、構造体にキーの能力をマークし、resource_group_memberというアノテーションを付けてオブジェクト・リソースを定義します。これにより、リストごとに個別のオブジェクト アカウントを作成し、関連するすべてのリスト情報をその中にカプセル化することができます。

ここでは、2つの主要な構造体を中心にマーケットプレイスを設計します

  • Listing
  • FixedPriceListing

の2つです。

このように分離することで、オークションのような動的な価格モデルをサポートするなど、将来の拡張性を確保できます。
各リスティングは独自のオブジェクトアカウントを作成し、Listingはトークン オブジェクトと売り手情報を保持し、FixedPriceListingは選択したCoinTypeで価格を指定します(CoinTypeは一般的な型パラメータとして指定されます)。

Listing リソース

今回のマーケットプレイスアプリでは下記の様に実装します。

#[resource_group_member(group = aptos_framework::object::ObjectGroup)]
struct Listing has key {
  /// The item owned by this listing, transferred to the new owner at the end.
	object: Object<ObjectCore>,
  /// The seller of the object.
	seller: address,
  /// Used to clean-up at the end.
	delete_ref: DeleteRef,
  /// Used to restore the object signer for transferring the escrowed object to the buyer.
	extend_ref: ExtendRef,
}

  • object:
    リストされるトークンオブジェクト。ここでは、token v2をサポートしています。これは、トークン・オブジェクトへのオブジェクト参照(またはポインタ)です。
  • seller:
    売り手アカウントのアドレス。
  • delete_ref:
    購入後にオブジェクトをクリーンアップするために必要です。
  • extend_ref:
    リスティング後、トークンをエスクローします(リスティングオブジェクトの署名者がトークンを保持します)ので、買い手が購入したいときにオブジェクトの署名者を復元し、object::transferを呼び出すためにExtendRefが必要です。

FixedPriceListing リソース

今回のマーケットプレイスアプリでは下記の様に実装します。

#[resource_group_member(group = aptos_framework::object::ObjectGroup)]
struct FixedPriceListing<phantom CoinType> has key {
  /// The price to purchase the item up for listing.
	price: u64,
}
  • price:
     リスティングしたNFTの価格です。

エントリーメソッドの実装

必要なデータを保存する部分の実装が完了したので次に必要な機能を実装していきたいと思います。

ここでは2つのメソッドを実装していきます。

  • list_with_fixed_price
    売り手がトークンを売りに出す関数
  • purchase
    買い手がリストされたトークンを購入する関数

list_with_fixed_price

このメソッドでNFTをリスティングさせることができます。

この関数では、売り手が所有する新しいオブジェクトを作成し、ListingリソースとFixedPriceListingリソースをオブジェクトに移動します。また、トークンをオブジェクトの署名者に転送し、エスクロー口座として機能させます。

objectモジュールからcreate_object関数を呼び出して、売り手のアカウントから新しいオブジェクトを作成します。このオブジェクトにListingとFixedPriceListingのリソースを格納します。

具体的には下記の様な実装となります。

/// List an time for sale at a fixed price.
public entry fun list_with_fixed_price<CoinType>(
    seller: &signer,
    object: Object<ObjectCore>,
    price: u64,
) {
    let constructor_ref = object::create_object(signer::address_of(seller));
    let transfer_ref = object::generate_transfer_ref(&constructor_ref);
    object::disable_ungated_transfer(&transfer_ref);

    let listing_signer = object::generate_signer(&constructor_ref);
    let listing = Listing {
        object,
        seller: signer::address_of(seller),
        delete_ref: object::generate_delete_ref(&constructor_ref),
        extend_ref: object::generate_extend_ref(&constructor_ref),
    };
    let fixed_price_listing = FixedPriceListing<CoinType> {
        price,
    };
    move_to(&listing_signer, listing);
    move_to(&listing_signer, fixed_price_listing);

    object::transfer(seller, object, signer::address_of(&listing_signer));
}

重要なポイントを解説していきます。

object::disable_ungated_transfer(&transfer_ref);

ここでは、オブジェクトのモジュールであるdisable_ungated_transfer関数を呼び出すことで、オブジェクトのゲートなし転送を不可にします。

let listing_signer = object::generate_signer(&constructor_ref);
let listing = Listing {
    object,
    seller: signer::address_of(seller),
    delete_ref: object::generate_delete_ref(&constructor_ref),
    extend_ref: object::generate_extend_ref(&constructor_ref),
};
let fixed_price_listing = FixedPriceListing<CoinType> {
    price,
};
move_to(&listing_signer, listing);
move_to(&listing_signer, fixed_price_listing);

新しいListingとFixedPriceListingリソースを作成し、オブジェクト署名者(ここではこのスマートコントラクト)に移動します。

object::transfer(seller, object, signer::address_of(&listing_signer));

最後にオブジェクトをオブジェクト署名者に転送してリスティングが完了します。

purchase

購入者は、固定価格リストから NFT を購入する際にこの関数を呼び出します。

この関数は、NFT を買い手に譲渡し、代金を売り手に譲渡します。また、Listing および FixedPriceListing リソースを含むオブジェクトを削除します。

具体的には下記の様に実装します。

/// Purchase outright an item from a fixed price listing.
public entry fun purchase<CoinType>(
  purchaser: &signer,
  object: Object<ObjectCore>,
) acquires FixedPriceListing, Listing {
  let listing_addr = object::object_address(&object);
  assert!(exists<Listing>(listing_addr), error::not_found(ENO_LISTING));
  assert!(exists<FixedPriceListing<CoinType>>(listing_addr), error::not_found(ENO_LISTING));

  let FixedPriceListing {
      price,
  } = move_from<FixedPriceListing<CoinType>>(listing_addr);

  // The listing has concluded, transfer the asset and delete the listing. Returns the seller
  // for depositing any profit.
  let coins = coin::withdraw<CoinType>(purchaser, price);

  let Listing {
      object,
      seller, // get seller from Listing object
      delete_ref,
      extend_ref,
  } = move_from<Listing>(listing_addr);

  let obj_signer = object::generate_signer_for_extending(&extend_ref);
  object::transfer(&obj_signer, object, signer::address_of(purchaser));
  object::delete(delete_ref); // Clean-up the listing object.

  aptos_account::deposit_coins(seller, coins);
}

重要なポイントを解説していきます。

assert!(exists<Listing>(listing_addr), error::not_found(ENO_LISTING));
assert!(exists<FixedPriceListing<CoinType>>(listing_addr), error::not_found(ENO_LISTING));

ここでは、指定されたオブジェクト・アドレスに Listing および FixedPriceListing リソースが存在するかどうかを、存在するネイティブ関数を使ってチェックします。

let FixedPriceListing {
    price,
} = move_from<FixedPriceListing<CoinType>>(listing_addr);

// The listing has concluded, transfer the asset and delete the listing. Returns the seller
// for depositing any profit.
let coins = coin::withdraw<CoinType>(purchaser, price);

ここでは、FixedPriceListingリソースをグローバルストレージ(オブジェクトアドレスの下)から移動し、NFTの価格を抽出します。次に、coin::withdraw 関数を使って購入者のアドレスから代金分のトークンを引き出します。

let Listing {
    object,
    seller, // get seller from Listing object
    delete_ref,
    extend_ref,
} = move_from<Listing>(listing_addr);

ここでは、リスティング・リソースをグローバル・ストレージ(オブジェクト・アドレスの下)から移動し、オブジェクト、セラー、delete_ref、extend_refフィールドを抽出しています。次の処理に備えるためですね。

let obj_signer = object::generate_signer_for_extending(&extend_ref);
object::transfer(&obj_signer, object, signer::address_of(purchaser));
object::delete(delete_ref); // Clean-up the listing object.

ここでは、NFTを買い手に譲渡し、ListingとFixedPriceListingリソースを含むオブジェクトを削除しています。また、extend_refを使用してオブジェクトの署名者を生成し、エスクローされたNFTを買い手に譲渡しています。

aptos_account::deposit_coins(seller, coins);

最後にaptos_account::deposit_coins関数を使用してNFTの販売者に支払いを送金します。

Move言語によるメソッドの実装方法についてさらに知りたい人は下記リンクを参照してください!!

https://aptos.dev/move/book/functions/

ここまででスマートコントラクトは全体で以下の様な実装になります。

module marketplace::list_and_purchase {
    use std::error;
    use std::signer;
    use std::option::{Self, Option};
    use aptos_std::smart_vector;
    use aptos_std::smart_vector::SmartVector;

    use aptos_framework::aptos_account;
    use aptos_framework::coin;
    use aptos_framework::object::{Self, DeleteRef, ExtendRef, Object, ObjectCore};

    #[test_only]
    friend marketplace::test_list_and_purchase;

    const APP_OBJECT_SEED: vector<u8> = b"MARKETPLACE";

    /// There exists no listing.
    const ENO_LISTING: u64 = 1;
    /// There exists no seller.
    const ENO_SELLER: u64 = 2;

    // Core data structures

    struct MarketplaceSigner has key {
        extend_ref: ExtendRef,
    }

    // In production we should use off-chain indexer to store all sellers instead of storing them on-chain.
    // Storing it on-chain is costly since it's O(N) to remove a seller.
    struct Sellers has key {
        /// All addresses of sellers.
        addresses: SmartVector<address>
    }

    #[resource_group_member(group = aptos_framework::object::ObjectGroup)]
    struct Listing has key {
        /// The item owned by this listing, transferred to the new owner at the end.
        object: Object<ObjectCore>,
        /// The seller of the object.
        seller: address,
        /// Used to clean-up at the end.
        delete_ref: DeleteRef,
        /// Used to create a signer to transfer the listed item, ideally the TransferRef would support this.
        extend_ref: ExtendRef,
    }

    #[resource_group_member(group = aptos_framework::object::ObjectGroup)]
    struct FixedPriceListing<phantom CoinType> has key {
        /// The price to purchase the item up for listing.
        price: u64,
    }

    // In production we should use off-chain indexer to store the listings of a seller instead of storing it on-chain.
    // Storing it on-chain is costly since it's O(N) to remove a listing.
    struct SellerListings has key {
        /// All object addresses of listings the user has created.
        listings: SmartVector<address>
    }

    // Functions

    // This function is only called once when the module is published for the first time.
    fun init_module(deployer: &signer) {
        let constructor_ref = object::create_named_object(
            deployer,
            APP_OBJECT_SEED,
        );
        let extend_ref = object::generate_extend_ref(&constructor_ref);
        let marketplace_signer = &object::generate_signer(&constructor_ref);

        move_to(marketplace_signer, MarketplaceSigner {
            extend_ref,
        });
    }

    public fun get_marketplace_signer_addr(): address {
        object::create_object_address(&@marketplace, APP_OBJECT_SEED)
    }

    public fun get_marketplace_signer(marketplace_signer_addr: address): signer acquires MarketplaceSigner {
        object::generate_signer_for_extending(&borrow_global<MarketplaceSigner>(marketplace_signer_addr).extend_ref)
    }

    /// List an time for sale at a fixed price.
    public entry fun list_with_fixed_price<CoinType>(
        seller: &signer,
        object: Object<ObjectCore>,
        price: u64,
    ) acquires SellerListings, Sellers, MarketplaceSigner {
        list_with_fixed_price_internal<CoinType>(seller, object, price);
    }

    public(friend) fun list_with_fixed_price_internal<CoinType>(
        seller: &signer,
        object: Object<ObjectCore>,
        price: u64,        
    ): Object<Listing> acquires SellerListings, Sellers, MarketplaceSigner {
        let constructor_ref = object::create_object(signer::address_of(seller));

        let transfer_ref = object::generate_transfer_ref(&constructor_ref);
        object::disable_ungated_transfer(&transfer_ref);

        let listing_signer = object::generate_signer(&constructor_ref);

        let listing = Listing {
            object,
            seller: signer::address_of(seller),
            delete_ref: object::generate_delete_ref(&constructor_ref),
            extend_ref: object::generate_extend_ref(&constructor_ref),
        };
        let fixed_price_listing = FixedPriceListing<CoinType> {
            price,
        };
        move_to(&listing_signer, listing);
        move_to(&listing_signer, fixed_price_listing);

        object::transfer(seller, object, signer::address_of(&listing_signer));

        let listing = object::object_from_constructor_ref(&constructor_ref);

        if (exists<SellerListings>(signer::address_of(seller))) {
            let seller_listings = borrow_global_mut<SellerListings>(signer::address_of(seller));
            smart_vector::push_back(&mut seller_listings.listings, object::object_address(&listing));
        } else {
            let seller_listings = SellerListings {
                listings: smart_vector::new(),
            };
            smart_vector::push_back(&mut seller_listings.listings, object::object_address(&listing));
            move_to(seller, seller_listings);
        };
        if (exists<Sellers>(get_marketplace_signer_addr())) {
            let sellers = borrow_global_mut<Sellers>(get_marketplace_signer_addr());
            if (!smart_vector::contains(&sellers.addresses, &signer::address_of(seller))) {
                smart_vector::push_back(&mut sellers.addresses, signer::address_of(seller));
            }
        } else {
            let sellers = Sellers {
                addresses: smart_vector::new(),
            };
            smart_vector::push_back(&mut sellers.addresses, signer::address_of(seller));
            move_to(&get_marketplace_signer(get_marketplace_signer_addr()), sellers);
        };

        listing
    }

    /// Purchase outright an item from a fixed price listing.
    public entry fun purchase<CoinType>(
        purchaser: &signer,
        object: Object<ObjectCore>,
    ) acquires FixedPriceListing, Listing, SellerListings, Sellers {
        let listing_addr = object::object_address(&object);
        
        assert!(exists<Listing>(listing_addr), error::not_found(ENO_LISTING));
        assert!(exists<FixedPriceListing<CoinType>>(listing_addr), error::not_found(ENO_LISTING));

        let FixedPriceListing {
            price,
        } = move_from<FixedPriceListing<CoinType>>(listing_addr);

        // The listing has concluded, transfer the asset and delete the listing. Returns the seller
        // for depositing any profit.

        let coins = coin::withdraw<CoinType>(purchaser, price);

        let Listing {
            object,
            seller, // get seller from Listing object
            delete_ref,
            extend_ref,
        } = move_from<Listing>(listing_addr);

        let obj_signer = object::generate_signer_for_extending(&extend_ref);
        object::transfer(&obj_signer, object, signer::address_of(purchaser));
        object::delete(delete_ref); // Clean-up the listing object.

        // Note this step of removing the listing from the seller's listings will be costly since it's O(N).
        // Ideally you don't store the listings in a vector but in an off-chain indexer
        let seller_listings = borrow_global_mut<SellerListings>(seller);
        let (exist, idx) = smart_vector::index_of(&seller_listings.listings, &listing_addr);
        assert!(exist, error::not_found(ENO_LISTING));
        smart_vector::remove(&mut seller_listings.listings, idx);

        if (smart_vector::length(&seller_listings.listings) == 0) {
            // If the seller has no more listings, remove the seller from the marketplace.
            let sellers = borrow_global_mut<Sellers>(get_marketplace_signer_addr());
            let (exist, idx) = smart_vector::index_of(&sellers.addresses, &seller);
            assert!(exist, error::not_found(ENO_SELLER));
            smart_vector::remove(&mut sellers.addresses, idx);
        };

        aptos_account::deposit_coins(seller, coins);
    }
}

ユニットテストを実装しよう!!

さぁここまででスマートコントラクトの実装が完了しました!!

ここからはスマートコントラクトが想定した通りに挙動するかどうかチェックするためのテストコードを実装していきます!!

前準備

まずテストコードを実装する前にヘルパーメソッドやViewメソッドなどをスマートコントラクトに追加していきます!

追加するのは以下のメソッドです。

  • ヘルパーメソッド

    • borrow_listing

      以下のメソッドを追加します。

      inline fun borrow_listing(object: Object<Listing>): &Listing acquires Listing {
            let obj_addr = object::object_address(&object);
      			assert!(exists<Listing>(obj_addr), error::not_found(ENO_LISTING));
            borrow_global<Listing>(obj_addr)
      }
      

      これは、オブジェクト内の Listing オブジェクトリソースをグローバルストレージから借用するためのヘルパー関数です。また、提供されたオブジェクトアドレス内にリスティングリソースが存在するかどうかもチェックしています。

  • Viewメソッド

    • price

      以下を追加します。

      #[view]
      public fun price<CoinType>(
          object: Object<Listing>,
      ): Option<u64> acquires FixedPriceListing {
          let listing_addr = object::object_address(&object);
          if (exists<FixedPriceListing<CoinType>>(listing_addr)) {
              let fixed_price = borrow_global<FixedPriceListing<CoinType>>(listing_addr);
              option::some(fixed_price.price)
          } else {
              // This should just be an abort but the compiler errors.
              assert!(false, error::not_found(ENO_LISTING));
              option::none()
          }
      }
      

      これは、Listingオブジェクトが参照するオブジェクトから FixedPriceListing に格納されているpriceの値を読み取る関数です。

    • listed_object

      以下を追加します。

      #[view]
      public fun listed_object(object: Object<Listing>): Object<ObjectCore> acquires Listing {
          let listing = borrow_listing(object);
          listing.object
      }
      

      これは、Listingオブジェクトが参照するオブジェクトから、リストされたトークン・オブジェクトを取得する関数です。

  • インラインメソッド

    • fixed_price_listing

      以下を追加します。

      inline fun fixed_price_listing(
          seller: &signer,
          price: u64
      ): (Object<Token>, Object<Listing>) {
          let token = test_utils::mint_tokenv2(seller);
          fixed_price_listing_with_token(seller, token, price)
      }
      
    • fixed_price_listing_with_token

      以下を追加します。

      inline fun fixed_price_listing_with_token(
          seller: &signer,
          token: Object<Token>,
          price: u64
      ): (Object<Token>, Object<Listing>) {
          let listing = list_and_purchase::list_with_fixed_price_internal<AptosCoin>(
              seller,
              object::convert(token), // Object<Token> -> Object<ObjectCore>
              price,
          );
          (token, listing)
      }
      

      この関数は list_with_fixed_price エントリー関数の後ろに位置し、リスティングオブジェクトの評価とテストを可能にします。

      test_utils::mint_tokenv2メソッドはこの後実装します。

テストコード実装

追加で実装が必要なメソッドを実装したのでいよいよユニットテストを実装していきます。

テストケースを書いていく前にテスト専用のモジュールを実装します。

test_utils.moveというファイルを作成します。

まずは必要なモジュールとsetUpメソッドを実装していきます。

#[test_only]
module marketplace::test_utils {
	use std::signer;
	use std::string;
	use std::vector;
	use aptos_framework::account;
	use aptos_framework::aptos_coin::{Self, AptosCoin};
	use aptos_framework::coin;
	use aptos_framework::object::{Self, Object};
	use aptos_token_objects::token::Token;
	use aptos_token_objects::aptos_token;
	use aptos_token_objects::collection::Collection;

	public inline fun setup(
    aptos_framework: &signer,
    marketplace: &signer,
    seller: &signer,
    purchaser: &signer,
	): (address, address, address) {
    let (burn_cap, mint_cap) = aptos_coin::initialize_for_test(aptos_framework);

    let marketplace_addr = signer::address_of(marketplace);
    account::create_account_for_test(marketplace_addr);
    coin::register<AptosCoin>(marketplace);

    let seller_addr = signer::address_of(seller);
    account::create_account_for_test(seller_addr);
    coin::register<AptosCoin>(seller);

    let purchaser_addr = signer::address_of(purchaser);
    account::create_account_for_test(purchaser_addr);
    coin::register<AptosCoin>(purchaser);

    let coins = coin::mint(10000, &mint_cap);
    coin::deposit(seller_addr, coins);
    let coins = coin::mint(10000, &mint_cap);
    coin::deposit(purchaser_addr, coins);

    coin::destroy_burn_cap(burn_cap);
    coin::destroy_mint_cap(mint_cap);

    (marketplace_addr, seller_addr, purchaser_addr)
  }
}

このsetUpメソッドではテストに必要なトークンやマーケットプレイスの設定を行っています。

次にテスト用のNFTを生成するメソッドを追加していきます!!

public fun mint_tokenv2_with_collection(seller: &signer): (Object<Collection>, Object<Token>) {
    let collection_name = string::utf8(b"collection_name");

    let collection_object = aptos_token::create_collection_object(
        seller,
        string::utf8(b"collection description"),
        2,
        collection_name,
        string::utf8(b"collection uri"),
        true,
        true,
        true,
        true,
        true,
        true,
        true,
        true,
        true,
        1,
        100,
    );

    let aptos_token = aptos_token::mint_token_object(
        seller,
        collection_name,
        string::utf8(b"description"),
        string::utf8(b"token_name"),
        string::utf8(b"uri"),
        vector::empty(),
        vector::empty(),
        vector::empty(),
    );
    (object::convert(collection_object), object::convert(aptos_token))
}

public fun mint_tokenv2(seller: &signer): Object<Token> {
    let (_collection, token) = mint_tokenv2_with_collection(seller);
    token
}

ここまでで test_utils.moveファイルは以下の様になっているはずです。

#[test_only]
module marketplace::test_utils {
    use std::signer;
    use std::string;
    use std::vector;

    use aptos_framework::account;
    use aptos_framework::aptos_coin::{Self, AptosCoin};
    use aptos_framework::coin;
    use aptos_framework::object::{Self, Object};

    use aptos_token_objects::token::Token;
    use aptos_token_objects::aptos_token;
    use aptos_token_objects::collection::Collection;
    use marketplace::list_and_purchase;

    public inline fun setup(
        aptos_framework: &signer,
        marketplace: &signer,
        seller: &signer,
        purchaser: &signer,
    ): (address, address, address) {
        list_and_purchase::setup_test(marketplace);
        let (burn_cap, mint_cap) = aptos_coin::initialize_for_test(aptos_framework);

        let marketplace_addr = signer::address_of(marketplace);
        account::create_account_for_test(marketplace_addr);
        coin::register<AptosCoin>(marketplace);

        let seller_addr = signer::address_of(seller);
        account::create_account_for_test(seller_addr);
        coin::register<AptosCoin>(seller);

        let purchaser_addr = signer::address_of(purchaser);
        account::create_account_for_test(purchaser_addr);
        coin::register<AptosCoin>(purchaser);

        let coins = coin::mint(10000, &mint_cap);
        coin::deposit(seller_addr, coins);
        let coins = coin::mint(10000, &mint_cap);
        coin::deposit(purchaser_addr, coins);

        coin::destroy_burn_cap(burn_cap);
        coin::destroy_mint_cap(mint_cap);

        (marketplace_addr, seller_addr, purchaser_addr)
    }

    public fun mint_tokenv2_with_collection(seller: &signer): (Object<Collection>, Object<Token>) {
        let collection_name = string::utf8(b"collection_name");

        let collection_object = aptos_token::create_collection_object(
            seller,
            string::utf8(b"collection description"),
            2,
            collection_name,
            string::utf8(b"collection uri"),
            true,
            true,
            true,
            true,
            true,
            true,
            true,
            true,
            true,
            1,
            100,
        );

        let aptos_token = aptos_token::mint_token_object(
            seller,
            collection_name,
            string::utf8(b"description"),
            string::utf8(b"token_name"),
            string::utf8(b"uri"),
            vector::empty(),
            vector::empty(),
            vector::empty(),
        );
        (object::convert(collection_object), object::convert(aptos_token))
    }

    public fun mint_tokenv2(seller: &signer): Object<Token> {
        let (_collection, token) = mint_tokenv2_with_collection(seller);
        token
    }
}

テストのためのメソッドを実装したのでテストケースを実装していきます。

#[test_only]
module marketplace::test_list_and_purchase {
  use std::option;
  use aptos_framework::aptos_coin::AptosCoin;
  use aptos_framework::coin;
  use aptos_framework::object::{Self, Object, ObjectCore};
  use aptos_token_objects::token::Token;
  use marketplace::list_and_purchase::{Self, Listing};
  use marketplace::test_utils;

  // Test that a fixed price listing can be created and purchased.
  #[test(aptos_framework = @0x1, marketplace = @0x111, seller = @0x222, purchaser = @0x333)]
  fun test_fixed_price(
      aptos_framework: &signer,
      marketplace: &signer,
      seller: &signer,
      purchaser: &signer,
  ) {
      let (_marketplace_addr, seller_addr, purchaser_addr) =
          test_utils::setup(aptos_framework, marketplace, seller, purchaser);

      let (token, listing) = fixed_price_listing(seller, 500); // price: 500

      assert!(list_and_purchase::listed_object(listing) == object::convert(token), 0); // The token is listed.
      assert!(list_and_purchase::price<AptosCoin>(listing) == option::some(500), 0); // The price is 500.
      assert!(object::owner(token) == object::object_address(&listing), 0); // The token is owned by the listing object. (escrowed)

      list_and_purchase::purchase<AptosCoin>(purchaser, object::convert(listing));

      assert!(object::owner(token) == purchaser_addr, 0); // The token has been transferred to the purchaser.
      assert!(coin::balance<AptosCoin>(seller_addr) == 10500, 0); // The seller has been paid.
      assert!(coin::balance<AptosCoin>(purchaser_addr) == 9500, 0); // The purchaser has paid.
  }
}

このテストケースでは、トークンが正しく取引されて想定した通りにownerや残高が変わっているかどうかをチェックしています。

// Test that the purchase fails if the purchaser does not have enough coin.
#[test(aptos_framework = @0x1, marketplace = @0x111, seller = @0x222, purchaser = @0x333)]
#[expected_failure(abort_code = 0x10006, location = aptos_framework::coin)]
fun test_not_enough_coin_fixed_price(
    aptos_framework: &signer,
    marketplace: &signer,
    seller: &signer,
    purchaser: &signer,
) {
    test_utils::setup(aptos_framework, marketplace, seller, purchaser);

    let (_token, listing) = fixed_price_listing(seller, 100000); // price: 100000

    list_and_purchase::purchase<AptosCoin>(purchaser, object::convert(listing));
}

購入するために十分なトークンを持っていなかった場合に失敗するかどうかを確認します。

// Test that the purchase fails if the listing object does not exist.
#[test(aptos_framework = @0x1, marketplace = @0x111, seller = @0x222, purchaser = @0x333)]
#[expected_failure(abort_code = 0x60001, location = marketplace::list_and_purchase)]
fun test_no_listing(
  aptos_framework: &signer,
  marketplace: &signer,
  seller: &signer,
  purchaser: &signer,
) {
  let (_, seller_addr, _) = test_utils::setup(aptos_framework, marketplace, seller, purchaser);

  let dummy_constructor_ref = object::create_object(seller_addr);
  let dummy_object = object::object_from_constructor_ref<ObjectCore>(&dummy_constructor_ref);

  list_and_purchase::purchase<AptosCoin>(purchaser, object::convert(dummy_object));
}

存在しないオブジェクトを取引しようとしたときにエラーが発生することを確認します。

テストケースの実装はここまでです!!

最終的にはマーケットプレイスのスマートコントラクトのソースコードは以下のようになります!!

module marketplace::list_and_purchase {
    use std::error;
    use std::signer;
    use std::option::{Self, Option};
    use aptos_std::smart_vector;
    use aptos_std::smart_vector::SmartVector;

    use aptos_framework::aptos_account;
    use aptos_framework::coin;
    use aptos_framework::object::{Self, DeleteRef, ExtendRef, Object, ObjectCore};

    #[test_only]
    friend marketplace::test_list_and_purchase;

    const APP_OBJECT_SEED: vector<u8> = b"MARKETPLACE";

    /// There exists no listing.
    const ENO_LISTING: u64 = 1;
    /// There exists no seller.
    const ENO_SELLER: u64 = 2;

    // Core data structures

    struct MarketplaceSigner has key {
        extend_ref: ExtendRef,
    }

    // In production we should use off-chain indexer to store all sellers instead of storing them on-chain.
    // Storing it on-chain is costly since it's O(N) to remove a seller.
    struct Sellers has key {
        /// All addresses of sellers.
        addresses: SmartVector<address>
    }

    #[resource_group_member(group = aptos_framework::object::ObjectGroup)]
    struct Listing has key {
        /// The item owned by this listing, transferred to the new owner at the end.
        object: Object<ObjectCore>,
        /// The seller of the object.
        seller: address,
        /// Used to clean-up at the end.
        delete_ref: DeleteRef,
        /// Used to create a signer to transfer the listed item, ideally the TransferRef would support this.
        extend_ref: ExtendRef,
    }

    #[resource_group_member(group = aptos_framework::object::ObjectGroup)]
    struct FixedPriceListing<phantom CoinType> has key {
        /// The price to purchase the item up for listing.
        price: u64,
    }

    // In production we should use off-chain indexer to store the listings of a seller instead of storing it on-chain.
    // Storing it on-chain is costly since it's O(N) to remove a listing.
    struct SellerListings has key {
        /// All object addresses of listings the user has created.
        listings: SmartVector<address>
    }

    // Functions

    // This function is only called once when the module is published for the first time.
    fun init_module(deployer: &signer) {
        let constructor_ref = object::create_named_object(
            deployer,
            APP_OBJECT_SEED,
        );
        let extend_ref = object::generate_extend_ref(&constructor_ref);
        let marketplace_signer = &object::generate_signer(&constructor_ref);

        move_to(marketplace_signer, MarketplaceSigner {
            extend_ref,
        });
    }

    public fun get_marketplace_signer_addr(): address {
        object::create_object_address(&@marketplace, APP_OBJECT_SEED)
    }

    public fun get_marketplace_signer(marketplace_signer_addr: address): signer acquires MarketplaceSigner {
        object::generate_signer_for_extending(&borrow_global<MarketplaceSigner>(marketplace_signer_addr).extend_ref)
    }

    /// List an time for sale at a fixed price.
    public entry fun list_with_fixed_price<CoinType>(
        seller: &signer,
        object: Object<ObjectCore>,
        price: u64,
    ) acquires SellerListings, Sellers, MarketplaceSigner {
        list_with_fixed_price_internal<CoinType>(seller, object, price);
    }

    public(friend) fun list_with_fixed_price_internal<CoinType>(
        seller: &signer,
        object: Object<ObjectCore>,
        price: u64,        
    ): Object<Listing> acquires SellerListings, Sellers, MarketplaceSigner {
        let constructor_ref = object::create_object(signer::address_of(seller));

        let transfer_ref = object::generate_transfer_ref(&constructor_ref);
        object::disable_ungated_transfer(&transfer_ref);

        let listing_signer = object::generate_signer(&constructor_ref);

        let listing = Listing {
            object,
            seller: signer::address_of(seller),
            delete_ref: object::generate_delete_ref(&constructor_ref),
            extend_ref: object::generate_extend_ref(&constructor_ref),
        };
        let fixed_price_listing = FixedPriceListing<CoinType> {
            price,
        };
        move_to(&listing_signer, listing);
        move_to(&listing_signer, fixed_price_listing);

        object::transfer(seller, object, signer::address_of(&listing_signer));

        let listing = object::object_from_constructor_ref(&constructor_ref);

        if (exists<SellerListings>(signer::address_of(seller))) {
            let seller_listings = borrow_global_mut<SellerListings>(signer::address_of(seller));
            smart_vector::push_back(&mut seller_listings.listings, object::object_address(&listing));
        } else {
            let seller_listings = SellerListings {
                listings: smart_vector::new(),
            };
            smart_vector::push_back(&mut seller_listings.listings, object::object_address(&listing));
            move_to(seller, seller_listings);
        };
        if (exists<Sellers>(get_marketplace_signer_addr())) {
            let sellers = borrow_global_mut<Sellers>(get_marketplace_signer_addr());
            if (!smart_vector::contains(&sellers.addresses, &signer::address_of(seller))) {
                smart_vector::push_back(&mut sellers.addresses, signer::address_of(seller));
            }
        } else {
            let sellers = Sellers {
                addresses: smart_vector::new(),
            };
            smart_vector::push_back(&mut sellers.addresses, signer::address_of(seller));
            move_to(&get_marketplace_signer(get_marketplace_signer_addr()), sellers);
        };

        listing
    }

    /// Purchase outright an item from a fixed price listing.
    public entry fun purchase<CoinType>(
        purchaser: &signer,
        object: Object<ObjectCore>,
    ) acquires FixedPriceListing, Listing, SellerListings, Sellers {
        let listing_addr = object::object_address(&object);
        
        assert!(exists<Listing>(listing_addr), error::not_found(ENO_LISTING));
        assert!(exists<FixedPriceListing<CoinType>>(listing_addr), error::not_found(ENO_LISTING));

        let FixedPriceListing {
            price,
        } = move_from<FixedPriceListing<CoinType>>(listing_addr);

        // The listing has concluded, transfer the asset and delete the listing. Returns the seller
        // for depositing any profit.

        let coins = coin::withdraw<CoinType>(purchaser, price);

        let Listing {
            object,
            seller, // get seller from Listing object
            delete_ref,
            extend_ref,
        } = move_from<Listing>(listing_addr);

        let obj_signer = object::generate_signer_for_extending(&extend_ref);
        object::transfer(&obj_signer, object, signer::address_of(purchaser));
        object::delete(delete_ref); // Clean-up the listing object.

        // Note this step of removing the listing from the seller's listings will be costly since it's O(N).
        // Ideally you don't store the listings in a vector but in an off-chain indexer
        let seller_listings = borrow_global_mut<SellerListings>(seller);
        let (exist, idx) = smart_vector::index_of(&seller_listings.listings, &listing_addr);
        assert!(exist, error::not_found(ENO_LISTING));
        smart_vector::remove(&mut seller_listings.listings, idx);

        if (smart_vector::length(&seller_listings.listings) == 0) {
            // If the seller has no more listings, remove the seller from the marketplace.
            let sellers = borrow_global_mut<Sellers>(get_marketplace_signer_addr());
            let (exist, idx) = smart_vector::index_of(&sellers.addresses, &seller);
            assert!(exist, error::not_found(ENO_SELLER));
            smart_vector::remove(&mut sellers.addresses, idx);
        };

        aptos_account::deposit_coins(seller, coins);
    }

    // Helper functions

    inline fun borrow_listing(object: Object<Listing>): &Listing acquires Listing {
        let obj_addr = object::object_address(&object);
        assert!(exists<Listing>(obj_addr), error::not_found(ENO_LISTING));
        borrow_global<Listing>(obj_addr)
    }

    // View functions

    #[view]
    public fun price<CoinType>(
        object: Object<Listing>,
    ): Option<u64> acquires FixedPriceListing {
        let listing_addr = object::object_address(&object);
        if (exists<FixedPriceListing<CoinType>>(listing_addr)) {
            let fixed_price = borrow_global<FixedPriceListing<CoinType>>(listing_addr);
            option::some(fixed_price.price)
        } else {
            // This should just be an abort but the compiler errors.
            assert!(false, error::not_found(ENO_LISTING));
            option::none()
        }
    }

    #[view]
    public fun listing(object: Object<Listing>): (Object<ObjectCore>, address) acquires Listing {
        let listing = borrow_listing(object);
        (listing.object, listing.seller)
    }

    #[view]
    public fun get_seller_listings(seller: address): vector<address> acquires SellerListings {
        if (exists<SellerListings>(seller)) {
            smart_vector::to_vector(&borrow_global<SellerListings>(seller).listings)
        } else {
            vector[]
        }
    }

    #[view]
    public fun get_sellers(): vector<address> acquires Sellers {
        if (exists<Sellers>(get_marketplace_signer_addr())) {
            smart_vector::to_vector(&borrow_global<Sellers>(get_marketplace_signer_addr()).addresses)
        } else {
            vector[]
        }
    }

    #[test_only]
    public fun setup_test(marketplace: &signer) {
        init_module(marketplace);
    }
}


// Unit tests

#[test_only]
module marketplace::test_list_and_purchase {

    use std::option;

    use aptos_framework::aptos_coin::AptosCoin;
    use aptos_framework::coin;
    use aptos_framework::object::{Self, Object, ObjectCore};

    use aptos_token_objects::token::Token;

    use marketplace::list_and_purchase::{Self, Listing};
    use marketplace::test_utils;

    // Test that a fixed price listing can be created and purchased.
    #[test(aptos_framework = @0x1, marketplace = @0x111, seller = @0x222, purchaser = @0x333)]
    fun test_fixed_price(
        aptos_framework: &signer,
        marketplace: &signer,
        seller: &signer,
        purchaser: &signer,
    ) {
        let (_marketplace_addr, seller_addr, purchaser_addr) =
            test_utils::setup(aptos_framework, marketplace, seller, purchaser);

        let (token, listing) = fixed_price_listing(seller, 500); // price: 500

        let (listing_obj, seller_addr2) = list_and_purchase::listing(listing);
        assert!(listing_obj == object::convert(token), 0); // The token is listed.
        assert!(seller_addr2 == seller_addr, 0); // The seller is the owner of the listing.
        assert!(list_and_purchase::price<AptosCoin>(listing) == option::some(500), 0); // The price is 500.
        assert!(object::owner(token) == object::object_address(&listing), 0); // The token is owned by the listing object. (escrowed)

        list_and_purchase::purchase<AptosCoin>(purchaser, object::convert(listing));

        assert!(object::owner(token) == purchaser_addr, 0); // The token has been transferred to the purchaser.
        assert!(coin::balance<AptosCoin>(seller_addr) == 10500, 0); // The seller has been paid.
        assert!(coin::balance<AptosCoin>(purchaser_addr) == 9500, 0); // The purchaser has paid.
    }

    // Test that the purchase fails if the purchaser does not have enough coin.
    #[test(aptos_framework = @0x1, marketplace = @0x111, seller = @0x222, purchaser = @0x333)]
    #[expected_failure(abort_code = 0x10006, location = aptos_framework::coin)]
    fun test_not_enough_coin_fixed_price(
        aptos_framework: &signer,
        marketplace: &signer,
        seller: &signer,
        purchaser: &signer,
    ) {
        test_utils::setup(aptos_framework, marketplace, seller, purchaser);

        let (_token, listing) = fixed_price_listing(seller, 100000); // price: 100000

        list_and_purchase::purchase<AptosCoin>(purchaser, object::convert(listing));
    }

    // Test that the purchase fails if the listing object does not exist.
    #[test(aptos_framework = @0x1, marketplace = @0x111, seller = @0x222, purchaser = @0x333)]
    #[expected_failure(abort_code = 0x60001, location = marketplace::list_and_purchase)]
    fun test_no_listing(
        aptos_framework: &signer,
        marketplace: &signer,
        seller: &signer,
        purchaser: &signer,
    ) {
        let (_, seller_addr, _) = test_utils::setup(aptos_framework, marketplace, seller, purchaser);

        let dummy_constructor_ref = object::create_object(seller_addr);
        let dummy_object = object::object_from_constructor_ref<ObjectCore>(&dummy_constructor_ref);

        list_and_purchase::purchase<AptosCoin>(purchaser, object::convert(dummy_object));
    }


    inline fun fixed_price_listing(
        seller: &signer,
        price: u64
    ): (Object<Token>, Object<Listing>) {
        let token = test_utils::mint_tokenv2(seller);
        fixed_price_listing_with_token(seller, token, price)
    }

    inline fun fixed_price_listing_with_token(
        seller: &signer,
        token: Object<Token>,
        price: u64
    ): (Object<Token>, Object<Listing>) {
        let listing = list_and_purchase::list_with_fixed_price_internal<AptosCoin>(
            seller,
            object::convert(token), // Object<Token> -> Object<ObjectCore>
            price,
        );
        (token, listing)
    }
}

実装はここまでです!!

ビルド&デプロイ

ではここからは実際に動かした時の記録をまとめていきます!!

まず初期化します。

aptos init --profile mashharuki

Move.tomlファイルを次のように設定します。

[addresses]と[dev-addresses]に設定するアドレスですが、初期化した際に生成される.aptosフォルダ配下のconfig.yamlファイルに記載されている値をセットします!!

[package]
name = "marketplace"
version = "1.0.0"
authors = []

[addresses]
marketplace = "0x9324da379576a6210929ee2611cd1995a21451d0be44ec92a92d17cae1d665ee"

[dependencies.AptosFramework]
git = "https://github.com/aptos-labs/aptos-core.git"
rev = "mainnet"
subdir = "aptos-move/framework/aptos-framework"

[dependencies.AptosTokenObjects]
git = "https://github.com/aptos-labs/aptos-core.git"
rev = "mainnet"
subdir = "aptos-move/framework/aptos-token-objects"


[dev-dependencies]

準備が整ったのでビルドしてみます!!

aptos move compile --named-addresses marketplace=mashharuki

以下の様になればビルド成功です!!

Compiling, may take a little while to download git dependencies...
FETCHING GIT DEPENDENCY https://github.com/aptos-labs/aptos-core.git
UPDATING GIT DEPENDENCY https://github.com/aptos-labs/aptos-core.git
INCLUDING DEPENDENCY AptosFramework
INCLUDING DEPENDENCY AptosStdlib
INCLUDING DEPENDENCY AptosTokenObjects
INCLUDING DEPENDENCY MoveStdlib
BUILDING marketplace
{
  "Result": [
    "9324da379576a6210929ee2611cd1995a21451d0be44ec92a92d17cae1d665ee::list_and_purchase"
  ]
}

ではテストも実行してみたいと思います!!!

aptos move test

全てPASSすれば成功なのですが、2024年3月24日時点ではテスト専用のモジュールで使っている一部のメソッドでバグが起きるみたいです。

関連するGitHubのIssueを共有します。

https://github.com/aptos-labs/aptos-core/issues/12505

テストが終わったらいよいよデプロイです!!

aptos move publish --named-addresses marketplace=mashharuki --profile=mashharuki

以下の様になれば成功です!!

Compiling, may take a little while to download git dependencies...
UPDATING GIT DEPENDENCY https://github.com/aptos-labs/aptos-core.git
UPDATING GIT DEPENDENCY https://github.com/aptos-labs/aptos-core.git
INCLUDING DEPENDENCY AptosFramework
INCLUDING DEPENDENCY AptosStdlib
INCLUDING DEPENDENCY AptosTokenObjects
INCLUDING DEPENDENCY MoveStdlib
BUILDING marketplace
package size 6151 bytes
Do you want to submit a transaction for a range of [388400 - 582600] Octas at a gas unit price of 100 Octas? [yes/no] >
yes
{
  "Result": {
    "transaction_hash": "0x4daf30d0e1d785e4ca490d044c852655509875e22b15ea1206ba3ed564af16c5",
    "gas_used": 3884,
    "gas_unit_price": 100,
    "sender": "9324da379576a6210929ee2611cd1995a21451d0be44ec92a92d17cae1d665ee",
    "sequence_number": 25,
    "success": true,
    "timestamp_us": 1711272405479454,
    "version": 981827343,
    "vm_status": "Executed successfully"
  }
}

Aptos Explorerでも確認ができます!!

https://explorer.aptoslabs.com/txn/0x4daf30d0e1d785e4ca490d044c852655509875e22b15ea1206ba3ed564af16c5?network=testnet

↓ こちらではデプロイしたスマートコントラクトのコードが確認できます!!

https://explorer.aptoslabs.com/account/0x9324da379576a6210929ee2611cd1995a21451d0be44ec92a92d17cae1d665ee/modules/code/list_and_purchase?network=testnet

無事にデプロイまでできました!!!

フロントエンドの実装

スマートコントラクトが無事にデプロイできたことを確認できたら次にフロントエンドの実装をみていきましょう!!

Next.jsで構築されています。前回の記事で取り上げていたフロントの実装とベースは同じようですね。

.
├── README.md
├── next.config.mjs
├── package.json
├── pnpm-lock.yaml
├── public
│   ├── next.svg
│   ├── pet-parts
│   └── vercel.svg
├── src
│   ├── app
│   ├── components
│   ├── context
│   ├── hooks
│   └── utils
└── tsconfig.json

ポイントになるファイルを中心に確認していきます。

まず、utils配下のaptos.tsから

import { Aptos, AptosConfig, Network, Account } from "@aptos-labs/ts-sdk";
import { Aptogotchi, AptogotchiTraits } from "./types";

export const APTOGOTCHI_CONTRACT_ADDRESS =
  "0x497c93ccd5d3c3e24a8226d320ecc9c69697c0dad5e1f195553d7eaa1140e91f";
export const COLLECTION_ID =
  "0xfce62045f3ac19160c1e88662682ccb6ef1173eba82638b8bae172cc83d8e8b8";
export const COLLECTION_CREATOR_ADDRESS =
  "0x714319fa1946db285254e3c7c75a9aac05277200e59429dd1f80f25272910d9c";
export const COLLECTION_NAME = "Aptogotchi Collection";
export const MARKETPLACE_CONTRACT_ADDRESS =
  "0xbf60e962f7e34a0c317cbcd9454a7125a1c3c3d15ec620688e0f357100284605";
export const APT = "0x1::aptos_coin::AptosCoin";
export const APT_UNIT = 100_000_000;

const config = new AptosConfig({
  network: Network.TESTNET,
});
export const aptos = new Aptos(config);

export const getAptogotchi = async (
  aptogotchiObjectAddr: string
): Promise<[string, AptogotchiTraits]> => {
  console.log("aptogotchiObjectAddr", aptogotchiObjectAddr);
  const aptogotchi = await aptos.view({
    payload: {
      function: `${APTOGOTCHI_CONTRACT_ADDRESS}::main::get_aptogotchi`,
      typeArguments: [],
      functionArguments: [aptogotchiObjectAddr],
    },
  });
  console.log(aptogotchi);
  return [aptogotchi[0] as string, aptogotchi[1] as AptogotchiTraits];
};

export const mintAptogotchi = async (
  sender: Account,
  name: string,
  body: number,
  ear: number,
  face: number
) => {
  const rawTxn = await aptos.transaction.build.simple({
    sender: sender.accountAddress,
    data: {
      function: `${APTOGOTCHI_CONTRACT_ADDRESS}::main::create_aptogotchi`,
      functionArguments: [name, body, ear, face],
    },
  });
  const pendingTxn = await aptos.signAndSubmitTransaction({
    signer: sender,
    transaction: rawTxn,
  });
  const response = await aptos.waitForTransaction({
    transactionHash: pendingTxn.hash,
  });
  console.log("minted aptogotchi. - ", response.hash);
};

export const getAptBalance = async (addr: string) => {
  const result = await aptos.getAccountCoinAmount({
    accountAddress: addr,
    coinType: APT,
  });

  console.log("APT balance", result);
  return result;
};

export const getCollection = async () => {
  // const collection = await aptos.getCollectionDataByCollectionId({
  //   collectionId: COLLECTION_ID,
  // });
  const collection = await aptos.getCollectionData({
    collectionName: COLLECTION_NAME,
    creatorAddress: COLLECTION_CREATOR_ADDRESS,
  });
  console.log("collection", collection);
  return collection;
};

export const getUserOwnedAptogotchis = async (ownerAddr: string) => {
  const result = await aptos.getAccountOwnedTokensFromCollectionAddress({
    accountAddress: ownerAddr,
    collectionAddress: COLLECTION_ID,
  });

  console.log("my aptogotchis", result);
  return result;
};

export const getAllAptogotchis = async () => {
  const result: {
    current_token_datas_v2: Aptogotchi[];
  } = await aptos.queryIndexer({
    query: {
      query: `
        query MyQuery($collectionId: String) {
          current_token_datas_v2(
            where: {collection_id: {_eq: $collectionId}}
          ) {
            name: token_name
            address: token_data_id
          }
        }
      `,
      variables: { collectionId: COLLECTION_ID },
    },
  });

  console.log("all aptogotchis", result.current_token_datas_v2);
  return result.current_token_datas_v2;
};

export const listAptogotchi = async (
  sender: Account,
  aptogotchiObjectAddr: string
) => {
  const rawTxn = await aptos.transaction.build.simple({
    sender: sender.accountAddress,
    data: {
      function: `${MARKETPLACE_CONTRACT_ADDRESS}::list_and_purchase::list_with_fixed_price`,
      typeArguments: [APT],
      functionArguments: [aptogotchiObjectAddr, 10],
    },
  });
  const pendingTxn = await aptos.signAndSubmitTransaction({
    signer: sender,
    transaction: rawTxn,
  });
  const response = await aptos.waitForTransaction({
    transactionHash: pendingTxn.hash,
  });
  console.log("listed aptogotchi. - ", response.hash);
};

export const buyAptogotchi = async (
  sender: Account,
  listingObjectAddr: string
) => {
  const rawTxn = await aptos.transaction.build.simple({
    sender: sender.accountAddress,
    data: {
      function: `${MARKETPLACE_CONTRACT_ADDRESS}::list_and_purchase::purchase`,
      typeArguments: [APT],
      functionArguments: [listingObjectAddr],
    },
  });
  const pendingTxn = await aptos.signAndSubmitTransaction({
    signer: sender,
    transaction: rawTxn,
  });
  const response = await aptos.waitForTransaction({
    transactionHash: pendingTxn.hash,
  });
  console.log("bought aptogotchi. - ", response.hash);
};

export const getAllListingObjectAddresses = async (sellerAddr: string) => {
  const allListings: [string[]] = await aptos.view({
    payload: {
      function: `${MARKETPLACE_CONTRACT_ADDRESS}::list_and_purchase::get_seller_listings`,
      typeArguments: [],
      functionArguments: [sellerAddr],
    },
  });
  console.log("all listings", allListings);
  return allListings[0];
};

export const getAllSellers = async () => {
  const allSellers: [string[]] = await aptos.view({
    payload: {
      function: `${MARKETPLACE_CONTRACT_ADDRESS}::list_and_purchase::get_sellers`,
      typeArguments: [],
      functionArguments: [],
    },
  });
  console.log("all sellers", allSellers);
  return allSellers[0];
};

export const getListingObjectAndSeller = async (
  listingObjectAddr: string
): Promise<[string, string]> => {
  const listingObjectAndSeller = await aptos.view({
    payload: {
      function: `${MARKETPLACE_CONTRACT_ADDRESS}::list_and_purchase::listing`,
      typeArguments: [],
      functionArguments: [listingObjectAddr],
    },
  });
  console.log("listing object and seller", listingObjectAndSeller);
  return [
    // @ts-ignore
    listingObjectAndSeller[0]["inner"] as string,
    listingObjectAndSeller[1] as string,
  ];
};

export const getListingObjectPrice = async (
  listingObjectAddr: string
): Promise<number> => {
  const listingObjectPrice = await aptos.view({
    payload: {
      function: `${MARKETPLACE_CONTRACT_ADDRESS}::list_and_purchase::price`,
      typeArguments: [APT],
      functionArguments: [listingObjectAddr],
    },
  });
  console.log("listing object price", JSON.stringify(listingObjectPrice));
  // @ts-ignore
  return (listingObjectPrice[0]["vec"] as number) / APT_UNIT;
};

コントラクトのアドレスや、Aptosクライアントオブジェクト、NFTのミント機能やNFT一覧を取得するなどスマートコントラクトの機能と直結した部分の実装がこのファイルに固まっています。

例えば、NFTをミントするメソッドは mintAptogotchiメソッドに実装されています。

export const mintAptogotchi = async (
  sender: Account,
  name: string,
  body: number,
  ear: number,
  face: number
) => {
  const rawTxn = await aptos.transaction.build.simple({
    sender: sender.accountAddress,
    data: {
      function: `${APTOGOTCHI_CONTRACT_ADDRESS}::main::create_aptogotchi`,
      functionArguments: [name, body, ear, face],
    },
  });
  const pendingTxn = await aptos.signAndSubmitTransaction({
    signer: sender,
    transaction: rawTxn,
  });
  const response = await aptos.waitForTransaction({
    transactionHash: pendingTxn.hash,
  });
  console.log("minted aptogotchi. - ", response.hash);
};

他には残高を取得するgetAptBalanceやNFT一覧を取得するgetCollectionなどのメソッドがあります。

export const getAptBalance = async (addr: string) => {
  const result = await aptos.getAccountCoinAmount({
    accountAddress: addr,
    coinType: APT,
  });

  console.log("APT balance", result);
  return result;
};
export const getCollection = async () => {
  // const collection = await aptos.getCollectionDataByCollectionId({
  //   collectionId: COLLECTION_ID,
  // });
  const collection = await aptos.getCollectionData({
    collectionName: COLLECTION_NAME,
    creatorAddress: COLLECTION_CREATOR_ADDRESS,
  });
  console.log("collection", collection);
  return collection;
};

フロントとウォレットを接続するための設定はWalletProvider.tsxにて実装されています!!

ここは前回と同じですね!!

"use client";

import { AptosWalletAdapterProvider } from "@aptos-labs/wallet-adapter-react";
import { PetraWallet } from "petra-plugin-wallet-adapter";
import { ReactNode } from "react";

const wallets = [new PetraWallet()];

export function WalletProvider({ children }: { children: ReactNode }) {
  return (
    <AptosWalletAdapterProvider plugins={wallets} autoConnect={true}>
      {children}
    </AptosWalletAdapterProvider>
  );
}

実際にフロントとウォレットと接続するロジックはWalletButton.tsxファイルに実装されている。

@aptos-labs/wallet-adapter-reactライブラリで用意されているメソッドを利用します。

"use client";

import {
  useWallet,
  WalletReadyState,
  Wallet,
  isRedirectable,
  WalletName,
} from "@aptos-labs/wallet-adapter-react";
import { Button, Text } from "@chakra-ui/react";

export const WalletButtons = () => {
  const { wallets, connected, disconnect, isLoading } = useWallet();

  if (connected) {
    return <Button onClick={disconnect}>Disconnect</Button>;
  }

  if (isLoading || !wallets[0]) {
    return <Text>Loading...</Text>;
  }

  return <WalletView wallet={wallets[0]} />;
};

const WalletView = ({ wallet }: { wallet: Wallet }) => {
  const { connect } = useWallet();
  const isWalletReady =
    wallet.readyState === WalletReadyState.Installed ||
    wallet.readyState === WalletReadyState.Loadable;
  const mobileSupport = wallet.deeplinkProvider;

  const onWalletConnectRequest = async (walletName: WalletName) => {
    try {
      connect(walletName);
    } catch (error) {
      console.warn(error);
      window.alert("Failed to connect wallet");
    }
  };

  /**
   * If we are on a mobile browser, adapter checks whether a wallet has a `deeplinkProvider` property
   * a. If it does, on connect it should redirect the user to the app by using the wallet's deeplink url
   * b. If it does not, up to the dapp to choose on the UI, but can simply disable the button
   * c. If we are already in a in-app browser, we don't want to redirect anywhere, so connect should work as expected in the mobile app.
   *
   * !isWalletReady - ignore installed/sdk wallets that don't rely on window injection
   * isRedirectable() - are we on mobile AND not in an in-app browser
   * mobileSupport - does wallet have deeplinkProvider property? i.e does it support a mobile app
   */
  if (!isWalletReady && isRedirectable()) {
    // wallet has mobile app
    if (mobileSupport) {
      return (
        // <button
        //   className={cn(buttonStyles, "hover:bg-blue-700")}
        //   disabled={false}
        //   key={wallet.name}
        //   onClick={() => onWalletConnectRequest(wallet.name)}
        //   style={{ maxWidth: "300px" }}
        // >
        //   Connect Wallet
        // </button>
        <Button onClick={() => onWalletConnectRequest(wallet.name)}>
          Connect Wallet
        </Button>
      );
    }
    // wallet does not have mobile app
    return (
      // <button
      //   className={cn(buttonStyles, "opacity-50 cursor-not-allowed")}
      //   disabled={true}
      //   key={wallet.name}
      //   style={{ maxWidth: "300px" }}
      // >
      //   Connect Wallet - Desktop Only
      // </button>
      <Button isDisabled={true}>Connect Wallet - Desktop Only</Button>
    );
  } else {
    // desktop
    return (
      // <button
      //   className={cn(
      //     buttonStyles,
      //     isWalletReady ? "hover:bg-blue-700" : "opacity-50 cursor-not-allowed"
      //   )}
      //   disabled={!isWalletReady}
      //   key={wallet.name}
      //   onClick={() => onWalletConnectRequest(wallet.name)}
      //   style={{ maxWidth: "300px" }}
      // >
      //   Connect Wallet
      // </button>
      <Button
        isDisabled={!isWalletReady}
        onClick={() => onWalletConnectRequest(wallet.name)}
      >
        Connect Wallet
      </Button>
    );
  }
};

getter系の処理は一部hooksしています。

例えばNFTを一覧で取得するhookは以下のような実装になっています。

import { getAllAptogotchis, getAptogotchi } from "@/utils/aptos";
import { AptogotchiWithTraits } from "@/utils/types";
import { useEffect, useState } from "react";

export const useGetAllNfts = () => {
  const [nfts, setNfts] = useState<AptogotchiWithTraits[]>();
  useEffect(() => {
    getAllAptogotchis().then(async (res) => {
      const aptogotchiWithTraits = [];
      for (const aptogotchi of res) {
        const [_, traits] = await getAptogotchi(aptogotchi.address);
        aptogotchiWithTraits.push({
          ...aptogotchi,
          ...traits,
        });
      }
      setNfts(aptogotchiWithTraits);
    });
  }, []);
  return nfts;
};

売りに出したNFTを取得するためのhookの実装は以下の様になっています。

import {
  getAllListingObjectAddresses,
  getAptogotchi,
  getListingObjectAndSeller,
  getListingObjectPrice,
} from "@/utils/aptos";
import { ListedAptogotchiWithTraits } from "@/utils/types";
import { useEffect, useState } from "react";

export const useGetListedNftsBySeller = (sellerAddr: string) => {
  const [nfts, setNfts] = useState<ListedAptogotchiWithTraits[]>();
  useEffect(() => {
    getAllListingObjectAddresses(sellerAddr).then(
      async (listingObjectAddresses) => {
        const aptogotchiWithTraits = [];
        for (const listingObjectAddress of listingObjectAddresses) {
          const [nftAddress, sellerAddress] = await getListingObjectAndSeller(
            listingObjectAddress
          );
          const price = await getListingObjectPrice(listingObjectAddress);
          const [name, traits] = await getAptogotchi(nftAddress);
          aptogotchiWithTraits.push({
            name,
            address: nftAddress,
            ...traits,
            listing_object_address: listingObjectAddress,
            seller_address: sellerAddress,
            price,
          });
        }
        setNfts(aptogotchiWithTraits);
      }
    );
  }, [sellerAddr]);
  return nfts;
};

重要なポイントは確認できました!

以下コマンドでビルドしましょう!

pnpm run build

ビルドできたらフロントエンドを起動します。

pnpm run dev

では操作画面の確認です。

まずはHome画面です。

この画面では全てのNFT、売りに出されている全てのNFTが確認できます。

売りに出されているNFTは購入することできます。

Mint画面では、NFTをミントすることができます!!

MintされたNFTはMyPortfolio画面で確認できます!!

自分のNFTは任意の価格でNFTを売りに出すことができます!!

確かに売りに出されています!!

今度は売りに出されているNFTを購入してみます!!

購入してもう一度 MyPortfolio画面を確認するとNFTが増えています!!

NFTマーケットプレイスの一通りの機能の解説は以上です!!

今回はここまでになります!!

皆さんもAptosとMoveを使って何かアプリケーションをビルドしてみてはいかがでしょうか??

読んでいただきありがとうございました!!

GitHubで編集を提案

Discussion