🦁

Aptosを使ってたまごっちライクなブロックチェーンゲームを作ってみよう!! 〜Part 1〜

2024/03/09に公開

はじめに

皆さん、こんにちは!

今回はパブリックブロックチェーンの一つである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とは

まず Aptos というブロックチェーンについて簡単に解説していきたいと思います。

Aptosは、L1(Layer 1)ブロックチェーンやそのプロジェクトの総称で、高処理能力・低遅延を強みとしているバプリックブロックチェーンになります!!

スマートコントラクトを扱うこともできるため、AvalancheSolanaSuiEthereumといったL1チェーンと競合するプロジェクトです。

Aptosは、Diemの元メンバーであるMo Shaikh氏・Avery Ching氏が中心となって開発がスタートしていて、ライバルプロジェクトとしては、Suiが挙げられます。

googleともパートナーシップを提携しており、Web3開発環境の整備やハッカソンの開催も企画されているということで、これまでのブロックチェーンプロジェクトとはまた違ったアプローチで世の中への普及を図ろうとしています。

Aptosについては、以前記事にまとめていますので詳細な説明はここまでにしますが興味がある方はぜひ下記記事もご覧ください!!

https://zenn.dev/mashharuki/articles/49359206592aa8

Aptosとライバル関係にあたるSuiについても記事にしてまとめているので合わせてSuiについても知りたいという方は下記記事もご覧ください!!

https://zenn.dev/mashharuki/articles/9eaf96ede16d48

Move言語について

Move言語については White Yuseiさんの資料の解説が素晴らしいので共有いたします!!

https://www.canva.com/design/DAF_tifX6Uw/q9VL8oMJM4f2MY-hzeAa_A/view

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

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

https://learn.aptoslabs.com/example/aptogotchi-beginner

Aptos上でたまごっちライクなブロックチェーンゲームが構築できるようです!!

https://aptogotchi.aptoslabs.com/

本当にたまごっちに似てますね・・・。

下記ページの動画ではこの学習コンテンツで開発するDappの概要を説明してくれます。

https://learn.aptoslabs.com/beginner/demo

GitHubは以下になります!!

https://github.com/mashharuki/aptogotchi

この学習コンテンツで学べること

この学習コンテンツをクリアすることで以下の要素を学ぶことができます!!

環境構築

まず、環境構築を行いましょう!!

VSCodeやGit、pnpm、GitHubなど基本的なツール等の設定は済んでいるものとして進めます。

ちなみに試した時の私の環境情報は以下の通りです。

git 2.27.0
pnpm 8.6.1
aptos 3.0.1

まずは、Aptos CLI をインストールします。

https://aptos.dev/tools/aptos-cli/install-cli/

上記サイトに手順に従って次のコマンドを打って結果が表示されれば完了です。

aptos help

その他にもCLIには便利な機能がいろいろ実装されているみたいなので気になる方は下記を調べてみてください!!

https://aptos.dev/tools/aptos-cli/

次に Aptos SDK をインストールします。

https://github.com/aptos-labs/aptos-ts-sdk?tab=readme-ov-file#installation

Walletもインストールしておきましょう!

https://chromewebstore.google.com/detail/petra-aptos-wallet/ejjladinnckdgjemekebdpeokbikhfci?hl=ja&pli=1

Move言語のプラグインもVS codeにインストールしておきましょう!!

https://plugins.jetbrains.com/plugin/14721-move-language

https://marketplace.visualstudio.com/items?itemName=damirka.move-syntax

さてここまで準備ができたら上で紹介したGitHubをフォークしてローカル環境にクローンしてきましょう!!

git clone https://github.com/mashharuki/aptogotchi

まずはビルドできるかチェックします。

  • フロントエンド側

    cd frontend && pnpm install
    
    pnpm run build
    

    ビルドされました!

      Route (app)                                Size     First Load JS
      ─ ○ /                                      22.1 kB         201 kB
      + First Load JS shared by all              179 kB
        ├ chunks/2267893a-04d9994a89532f8b.js    21.1 kB
        ├ chunks/437-3f38d81376993395.js         25.7 kB
        ├ chunks/794-ee75fc218f9574e6.js         79.5 kB
        ├ chunks/d909b7fe-0e182e62c4d25929.js    50.6 kB
        ├ chunks/main-app-86de5ad27bf18836.js    213 B
        └ chunks/webpack-ca644b33a8e5dcff.js     1.74 kB
      
      Route (pages)                              Size     First Load JS
      ─ ○ /404                                   181 B          75.7 kB
      + First Load JS shared by all              75.5 kB
        ├ chunks/framework-510ec8ffd65e1d01.js   45 kB
        ├ chunks/main-496fbdd0b468e353.js        28.5 kB
        ├ chunks/pages/_app-4fa603cb0fa6e977.js  195 B
        └ chunks/webpack-ca644b33a8e5dcff.js     1.74 kB
    
      ○  (Static)  automatically rendered as static HTML (uses no initial props)
    
      - info Creating an optimized production build .%   
    

    ここまで問題なければフロントエンドが起動できるはずなので下記コマンドで立ち上げます。

    pnpm run dev
    

    http://localhost:3000にアクセスして確認しましょう!

    こういう画面が出ていればOKです!

  • スマートコントラクト側

    次にスマートコントラクト側が問題なく動くか確認しましょう!

    まず下記コマンドでウォレットを作成します。この時秘密鍵が出力されるのでpetraWalletの方でインポートしておきましょう!!

    aptos init --profile mashharuki    
    

    問題なければ下記のように出力されるはずです。

    {
      "Result": "Success"
    }
    

    PetraWalletにインポートした後は、faucetボタンを押していくらかネイティブトークンを入手しておきましょう!!

    次にスマートコントラクトがビルドできるか確認します。

    設定ファイルであるMove.tomlファイルを一部書き換える必要があります。

    [address]と[dev-address]のところをさっき作成したウォレットのアドレスに書き換えましょう。

    [package]
    name = "aptogotchi"
    version = "1.0.0"
    upgrade_policy = "compatible"
    
    [addresses]
    aptogotchi = "0xaa845b38dd50f39e8be7a66a6368d26b9d53d813ecc69ab2f4d142a6952ee2f8"
    
    [dependencies]
    AptosFramework = { git = "https://github.com/aptos-labs/aptos-core.git", rev = "main", subdir = "aptos-move/framework/aptos-framework"}
    AptosTokenObjects = {git = "https://github.com/aptos-labs/aptos-core.git", rev = "main", subdir = "aptos-move/framework/aptos-token-objects"}
    
    # For use when developing and testing this module
    [dev-addresses]
    aptogotchi = "0xaa845b38dd50f39e8be7a66a6368d26b9d53d813ecc69ab2f4d142a6952ee2f8"
    

    これで準備が整いました。下記コマンドでビルドします!

    aptos move compile --named-addresses aptogotchi=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 aptogotchi
      {
        "Result": [
          "aa845b38dd50f39e8be7a66a6368d26b9d53d813ecc69ab2f4d142a6952ee2f8::main"
        ]
      }
    

    とりあえずフォークしてきたリポジトリが問題なく動くことができたので、学習コンテンツを読み進めていきます。

フロントエンド側のソースコード解説

学習コンテンツに沿って読み進めていきます!
まずはフロントエンドからのようです!

Next.jsとTailwindCSSが採用されています。

まずはAptos SDKを使ってどのようにブロックチェーンのデータを読み取るのか学んでいきます!!

frontend/src/app/home/Connected.tsxファイルを確認していきます!!

"use client";

import { useState, useEffect, useCallback } from "react";
import { Pet } from "./Pet";
import { useWallet } from "@aptos-labs/wallet-adapter-react";
import { Mint } from "./Mint";
import { NEXT_PUBLIC_CONTRACT_ADDRESS } from "@/utils/env";
import { getAptosClient } from "@/utils/aptosClient";
import { Modal } from "@/components/Modal";

const TESTNET_ID = "2";

const aptosClient = getAptosClient();

/**
 * Connected コンポーネント
 * @returns 
 */
export function Connected() {
  const [pet, setPet] = useState<Pet>();
  const { account, network } = useWallet();

  /**
   * スマートコントラクトからデータを読み取るメソッド
   */
  const fetchPet = useCallback(async () => {
    if (!account?.address) return;

    // has_aptogotchiメソッドを呼び出してAptogochiを所持しているか確認する。
    const [hasPet] = await aptosClient.view({
      payload: {
        function: `${NEXT_PUBLIC_CONTRACT_ADDRESS}::main::has_aptogotchi`,
        functionArguments: [account.address],
      },
    });
    if (hasPet as boolean) {
      // Aptogochiを所持していたら詳細情報を取得する。
      const response = await aptosClient.view({
        payload: {
          function: `${NEXT_PUBLIC_CONTRACT_ADDRESS}::main::get_aptogotchi`,
          functionArguments: [account.address],
        },
      });
      const [name, birthday, energyPoints, parts] = response;
      const typedParts = parts as { body: number; ear: number; face: number };
      setPet({
        name: name as string,
        birthday: birthday as number,
        energy_points: energyPoints as number,
        parts: typedParts,
      });
    }
  }, [account?.address]);

  useEffect(() => {
    if (!account?.address || !network) return;

    fetchPet();
  }, [account?.address, fetchPet, network]);

  return (
    <div className="flex flex-col gap-3 p-3">
      {network?.chainId !== TESTNET_ID && <Modal />}
      {pet ? <Pet pet={pet} setPet={setPet} /> : <Mint fetchPet={fetchPet} />}
    </div>
  );
}

このコンポーネントを眺めるだけで、スマートコントラクトからどのように情報を引っ張ってくるか分かりますね!!

例えば、Aptogochiを所持しているかどうかは下記のような実装になります。

// has_aptogotchiメソッドを呼び出してAptogochiを所持しているか確認する。
const [hasPet] = await aptosClient.view({
  payload: {
    function: `${NEXT_PUBLIC_CONTRACT_ADDRESS}::main::has_aptogotchi`,
    functionArguments: [account.address],
  },
});

${CONTRACT_ADDRESS}::main::get_aptogotchiが意味するものとして以下のように分解することができます。

読み込み系の処理は思ったよりもシンプルですね!

次に書き込み系の処理をみていきたいと思います!!

frontend/src/app/home/Pet/Actions.tsxファイルを見ていきます!

"use client";

import { Dispatch, SetStateAction, useState } from "react";
import { useWallet } from "@aptos-labs/wallet-adapter-react";
import { Pet } from ".";
import { getAptosClient } from "@/utils/aptosClient";
import {
  NEXT_PUBLIC_CONTRACT_ADDRESS,
  NEXT_PUBLIC_ENERGY_CAP,
  NEXT_PUBLIC_ENERGY_DECREASE,
  NEXT_PUBLIC_ENERGY_INCREASE,
} from "@/utils/env";

const aptosClient = getAptosClient();

export type PetAction = "feed" | "play";

export interface ActionsProps {
  pet: Pet;
  selectedAction: PetAction;
  setSelectedAction: (action: PetAction) => void;
  setPet: Dispatch<SetStateAction<Pet | undefined>>;
}

/**
 * Actions コンポーネント
 * @param param0 
 * @returns 
 */
export function Actions({
  selectedAction,
  setSelectedAction,
  setPet,
  pet,
}: ActionsProps) {
  const [transactionInProgress, setTransactionInProgress] =
    useState<boolean>(false);
  const { account, network, signAndSubmitTransaction } = useWallet();

  const handleStart = () => {
    switch (selectedAction) {
      case "feed":
        handleFeed();
        break;
      case "play":
        handlePlay();
        break;
    }
  };

  /**
   * Feed ボタンを押した時の処理
   * @returns 
   */
  const handleFeed = async () => {
    if (!account || !network) return;

    setTransactionInProgress(true);

    try {
      // feedメソッドを呼び出す。
      const response = await signAndSubmitTransaction({
        sender: account.address,
        data: {
          function: `${NEXT_PUBLIC_CONTRACT_ADDRESS}::main::feed`,
          typeArguments: [],
          functionArguments: [NEXT_PUBLIC_ENERGY_INCREASE],
        },
      });
      await aptosClient.waitForTransaction({ transactionHash: response.hash });

      setPet((pet) => {
        if (!pet) return pet;
        if (
          pet.energy_points + Number(NEXT_PUBLIC_ENERGY_INCREASE) >
          Number(NEXT_PUBLIC_ENERGY_CAP)
        )
          return pet;

        return {
          ...pet,
          energy_points:
            pet.energy_points + Number(NEXT_PUBLIC_ENERGY_INCREASE),
        };
      });
    } catch (error: any) {
      console.error(error);
    } finally {
      setTransactionInProgress(false);
    }
  };

  /**
   * Playボタンを押した時の処理
   * @returns 
   */
  const handlePlay = async () => {
    if (!account || !network) return;

    setTransactionInProgress(true);

    try {
      // スマートコントラクトのplayメソッドを呼び出す
      const response = await signAndSubmitTransaction({
        sender: account.address,
        data: {
          function: `${NEXT_PUBLIC_CONTRACT_ADDRESS}::main::play`,
          typeArguments: [],
          functionArguments: [NEXT_PUBLIC_ENERGY_DECREASE],
        },
      });
      await aptosClient.waitForTransaction({
        transactionHash: response.hash,
      });

      setPet((pet) => {
        if (!pet) return pet;
        if (pet.energy_points <= Number(NEXT_PUBLIC_ENERGY_DECREASE))
          return pet;

        return {
          ...pet,
          energy_points:
            pet.energy_points - Number(NEXT_PUBLIC_ENERGY_DECREASE),
        };
      });
    } catch (error: any) {
      console.error(error);
    } finally {
      setTransactionInProgress(false);
    }
  };

  const feedDisabled =
    selectedAction === "feed" &&
    pet.energy_points === Number(NEXT_PUBLIC_ENERGY_CAP);
  const playDisabled =
    selectedAction === "play" && pet.energy_points === Number(0);

  return (
    <div className="nes-container with-title flex-1 bg-white h-[320px]">
      <p className="title">Actions</p>
      <div className="flex flex-col gap-2 justify-between h-full">
        <div className="flex flex-col flex-shrink-0 gap-2 border-b border-gray-300">
          <label>
            <input
              type="radio"
              className="nes-radio"
              name="action"
              checked={selectedAction === "play"}
              onChange={() => setSelectedAction("play")}
            />
            <span>Play</span>
          </label>
          <label>
            <input
              type="radio"
              className="nes-radio"
              name="action"
              checked={selectedAction === "feed"}
              onChange={() => setSelectedAction("feed")}
            />
            <span>Feed</span>
          </label>
        </div>
        <div className="flex flex-col gap-4 justify-between">
          <p>{actionDescriptions[selectedAction]}</p>
          <button
            type="button"
            className={`nes-btn is-success ${
              feedDisabled || playDisabled ? "is-disabled" : ""
            }`}
            onClick={handleStart}
            disabled={transactionInProgress || feedDisabled || playDisabled}
          >
            {transactionInProgress ? "Processing..." : "Start"}
          </button>
        </div>
      </div>
    </div>
  );
}

const actionDescriptions: Record<PetAction, string> = {
  feed: "Feeding your pet will boost its Energy Points...",
  play: "Playing with your pet will make it happy and consume its Energy Points...",
};

読み込み系とは異なり、ちょっと複雑になってますね。

でも基本的にはSDKのメソッドを呼び出すだけです。 ethers.jsに似てますね!

スマートコントラクトのメソッドと引数をして``メソッドを利用することによりトランザクションの作成と署名・送信を行えるようです。

// スマートコントラクトのplayメソッドを呼び出す
const response = await signAndSubmitTransaction({
    sender: account.address,
    data: {
      function: `${NEXT_PUBLIC_CONTRACT_ADDRESS}::main::play`,
      typeArguments: [],
      functionArguments: [NEXT_PUBLIC_ENERGY_DECREASE],
    },
});
await aptosClient.waitForTransaction({
    transactionHash: response.hash,
});

次にウォレットとどのように接続させているのかを見ていきましょう!!

WalletProvider.tsxファイルを確認します!

"use client";

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

const wallets = [new PetraWallet()];

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

@aptos-labs/wallet-adapter-react ライブラリのAptosWalletAdapterProviderコンポーネントを利用するようです!

このようにするとWalletButtons/index.tsxファイル内で使われている通り、Walletコンポーネントやウォレットとの接続状況を確認できる変数やメソッドを任意のコンポーネントで呼び出して使うことができるようです。

このあたりもWagmiなどと似ていますね!

"use client";

import {
  useWallet,
  WalletReadyState,
  Wallet,
  isRedirectable,
  WalletName,
} from "@aptos-labs/wallet-adapter-react";
import { cn } from "@/utils/styling";

const buttonStyles = "nes-btn is-primary";

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

  if (connected) {
    return (
      <div className="flex flex-row">
        <div
          className={cn(buttonStyles, "hover:bg-blue-700 btn-small")}
          onClick={disconnect}
        >
          Disconnect
        </div>
      </div>
    );
  }

  if (isLoading || !wallets[0]) {
    return (
      <div className={cn(buttonStyles, "opacity-50 cursor-not-allowed")}>
        Loading...
      </div>
    );
  }

  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 {
      await 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>
      );
    }
    // 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>
    );
  } 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>
    );
  }
};

フロントエンド側の特筆すべき実装の解説についてはここまでになります!

スマートコントラクト側のソースコード解説

続いてMove言語で実装されたスマートコントラクト側の実装をみていきます!
Rustが苦手な方は辛いかもですが、頑張って読み進めていきましょう!!

NoirなどやっぱりRust系で実装が必要なケースが増えてきている印象ですので、場数を増やして少しでも慣れていくしかなさそうです!!

では解説です!

スマートコントラクトのソースコードは一つだけです!!

move/sources/aptogotchi.moveファイルのみです!

まずファイル全体のざっくりとした構成を見ていきましょう!!

次のような構成になっています!!

module aptogotchi::main {
  use std::vector;
  use std::string;

  struct AptoGotchi has key {

  }

  fun init_module(account: &signer) {

  }

  public entry fun create_aptogotchi() {

  }

  #[view]
  public fun get_name(user_addr: address): String acquires AptoGotchi, CollectionCapability {

  }

  public entry fun set_name(user_addr: address, name: String) acquires AptoGotchi, CollectionCapability {

  }
};

では細かく見ていきたいと思います!!

init_module

まずはコンストラクターのようなものにあたるinit_moduleです。

このメソッドはデプロイ時に一度だけ実行されるものです。

このメソッドはプライベートかつ戻り値を持つことができないという制約があります。

今回だと下記のような実装です。

fun init_module(account: &signer) {
    let constructor_ref = object::create_named_object(
        account,
        APP_OBJECT_SEED,
    );
    let extend_ref = object::generate_extend_ref(&constructor_ref);
    let app_signer = &object::generate_signer(&constructor_ref);

    move_to(account, MintAptogotchiEvents {
        mint_aptogotchi_events: account::new_event_handle<MintAptogotchiEvent>(account),
    });

    move_to(app_signer, CollectionCapability {
        extend_ref,
    });

    create_aptogotchi_collection(app_signer);
}

最後の行で create_aptogotchi_collection(app_signer);メソッドを呼び出していますね。これでAptogochi コレクションを生成しているみたいです。

次にデジタルアセットであるAptogochi コレクションを生成するメソッドを見ていきます!

// Create the collection that will hold all the Aptogotchis
fun create_aptogotchi_collection(creator: &signer) {
    let description = string::utf8(APTOGOTCHI_COLLECTION_DESCRIPTION);
    let name = string::utf8(APTOGOTCHI_COLLECTION_NAME);
    let uri = string::utf8(APTOGOTCHI_COLLECTION_URI);

    collection::create_unlimited_collection(
        creator,
        description,
        name,
        option::none(),
        uri,
    );
}

ここですね。この部分でAptogochiコレクションの雛形を生成しているみたいです!!

では次にAptogochiそのものを生成している部分の実装をみていきたいと思います!!

public entry fun create_aptogotchi(
        user: &signer,
        name: String,
        body: u8,
        ear: u8,
        face: u8,
    ) acquires CollectionCapability, MintAptogotchiEvents {
        assert!(string::length(&name) <= NAME_UPPER_BOUND, error::invalid_argument(ENAME_LIMIT));
        assert!(
            body >= 0 && body <= BODY_MAX_VALUE,
            error::invalid_argument(EBODY_VALUE_INVALID)
        );
        assert!(ear >= 0 && ear <= EAR_MAX_VALUE, error::invalid_argument(EEAR_VALUE_INVALID));
        assert!(
            face >= 0 && face <= FACE_MAX_VALUE,
            error::invalid_argument(EFACE_VALUE_INVALID)
        );

        let uri = string::utf8(APTOGOTCHI_COLLECTION_URI);
        let description = string::utf8(APTOGOTCHI_COLLECTION_DESCRIPTION);
        let user_addr = address_of(user);
        let token_name = to_string(&user_addr);
        let parts = AptogotchiParts {
            body,
            ear,
            face,
        };
        assert!(!has_aptogotchi(user_addr), error::already_exists(EUSER_ALREADY_HAS_APTOGOTCHI));

        let constructor_ref = token::create_named_token(
            &get_app_signer(),
            string::utf8(APTOGOTCHI_COLLECTION_NAME),
            description,
            token_name,
            option::none(),
            uri,
        );

        let token_signer = object::generate_signer(&constructor_ref);
        let mutator_ref = token::generate_mutator_ref(&constructor_ref);
        let burn_ref = token::generate_burn_ref(&constructor_ref);
        let transfer_ref = object::generate_transfer_ref(&constructor_ref);

        // initialize/set default Aptogotchi struct values
        let gotchi = Aptogotchi {
            name,
            birthday: timestamp::now_seconds(),
            energy_points: ENERGY_UPPER_BOUND,
            parts,
            mutator_ref,
            burn_ref,
        };

        move_to(&token_signer, gotchi);

        // Emit event for minting Aptogotchi token
        event::emit_event<MintAptogotchiEvent>(
            &mut borrow_global_mut<MintAptogotchiEvents>(@aptogotchi).mint_aptogotchi_events,
            MintAptogotchiEvent {
                token_name,
                aptogotchi_name: name,
                parts,
            },
        );

        object::transfer_with_ref(object::generate_linear_transfer_ref(&transfer_ref), address_of(user));
    }

このメソッドで呼び出し元のアドレスにAptogochiを渡しています!!

ERC721の規格に準拠したNFTなどを開発してきたエンジニアの方はこの実装を見て 「??」 となったり「!!」という反応になるはずです!!

ここがSolidityで実装するFTやNFTとは全く別ものであることが分かります!!

これはSuiにも言えることですが、aptos上ではデジタルアセットを生成してちゃんとそのアセットをゲットできたり他人に渡したりすることができるようになっているわけです!!

ERC721とかだと本当にアセットが生成されているわけではなく、ウォレットアドレスとトークンIDをマッピングしているだけの実装になっています。

あたかも本当にデジタルアセットをやり取りしているかのように見せているのはフロントエンドが非常に優れているからですね・・。

考えてみると至極当然なのですが、その当たり前のような実装がSolidityではできないわけです(データの持ち方がAptosやSuiとは全然違うので)。

デジタルアセットが単体としてちゃんとブロックチェーン上に存在するという実装ができるのはAptosの強みでしょう!!

この点については下記の記事でも詳しく解説しています!!

https://zenn.dev/mashharuki/articles/9eaf96ede16d48#4.-solidityで作るnftとの違い

なので生成したアセットごとにアドレスも生成されます!!

それを取得するのが下記のメソッドです。

// Get reference to Aptogotchi token object (CAN'T modify the reference)
fun get_aptogotchi_address(creator_addr: &address): (address) acquires CollectionCapability {
  let collection = string::utf8(APTOGOTCHI_COLLECTION_NAME);
  let token_name = to_string(creator_addr);
  let creator = &get_token_signer();
  let token_address = token::create_token_address(
      &signer::address_of(creator),
      &collection,
      &token_name,
  );

  token_address
}

残りは書き込み系と読み込み系のメソッドの解説です!!

Move言語にもsolidityと同じく諸々の修飾子をつけることができます!
今回のその種類について解説していきます!!

  • Public Functions
    Public Functionは、他のコントラクトからも呼び出せたりするメソッドです。

    今回だとaptogochiを取得するメソッドなどがそれに該当します。

      // Returns all fields for this Aptogotchi (if found)
      #[view]
      public fun get_aptogotchi(
          owner_addr: address
      ): (String, u64, u64, vector<u8>) acquires AptoGotchi, CollectionCapability {
          // if this address doesn't have an Aptogotchi, throw error
          assert!(has_aptogotchi(owner_addr), error::unavailable(ENOT_AVAILABLE));
    
          let token_address = get_aptogotchi_address(&owner_addr);
          let gotchi = borrow_global_mut<AptoGotchi>(token_address);
    
          // view function can only return primitive types.
          (gotchi.name, gotchi.birthday, gotchi.energy_points, gotchi.parts)
      }
    
  • Entry Functions

    エントリー functionは、フロントエンド側から直接呼び出すメソッドで強制的に public functionの扱いになるみたいです。

今回だとset_nameメソッドが該当するみたいです。

```rs
public entry fun set_name(owner: signer, name: String) acquires AptoGotchi, CollectionCapability {
    let owner_addr = signer::address_of(&owner);
    assert!(has_aptogotchi(owner_addr), error::unavailable(ENOT_AVAILABLE));
    assert!(string::length(&name)<=NAME_UPPER_BOUND,error::invalid_argument(ENAME_LIMIT));
    let token_address = get_aptogotchi_address(&owner_addr);
    let gotchi = borrow_global_mut<AptoGotchi>(token_address);
    gotchi.name = name;
}

```
  • View Functions

    View functionは、読み込み系専用の修飾子で更新はできないメソッドになります。

    今回だと上で紹介したaptogochiを取得するメソッドで使用されています。

  • Inline Functions

    Inline functionは、呼び出された関数のコードを呼び出し元の関数に直接挿入するコンパイラの最適化のために使用されるようです。

    下記のような使われ方をされるみたいです。

    inline fun get_aptogotchi_internal(creator_addr: &address) { }
    
  • Private Functions

    Privateメソッドは外からは実行することができず、同じコントラクトの内部関数やヘルパー関数として実装したい時に使用することになります。

今回だとAptogochiのアドレスを取得するメソッドが該当します。

  fun get_aptogotchi_address(creator_addr: &address): (address) acquires CollectionCapability {
     let collection = string::utf8(APTOGOTCHI_COLLECTION_NAME);
     let token_name = to_string(creator_addr);
     let creator = &get_token_signer();
     let token_address = token::create_token_address(
         &signer::address_of(creator),
         &collection,
         &token_name,
     );

     token_address
 }
  • The acquires Keyword

    これはSolidityでは無かったものですね。

アセットを移動させようとするときにメソッドに記述が必要となる修飾子のようです。

今回だとAptogochiを作成して呼び出し元のアドレスに渡すメソッドがありましたがまさにあれが該当します。

public entry fun create_aptogotchi(
      user: &signer,
      name: String,
      body: u8,
      ear: u8,
      face: u8,
  ) acquires CollectionCapability, MintAptogotchiEvents {
      assert!(string::length(&name) <= NAME_UPPER_BOUND, error::invalid_argument(ENAME_LIMIT));
      assert!(
          body >= 0 && body <= BODY_MAX_VALUE,
          error::invalid_argument(EBODY_VALUE_INVALID)
      );
      assert!(ear >= 0 && ear <= EAR_MAX_VALUE, error::invalid_argument(EEAR_VALUE_INVALID));
      assert!(
          face >= 0 && face <= FACE_MAX_VALUE,
          error::invalid_argument(EFACE_VALUE_INVALID)
      );

      let uri = string::utf8(APTOGOTCHI_COLLECTION_URI);
      let description = string::utf8(APTOGOTCHI_COLLECTION_DESCRIPTION);
      let user_addr = address_of(user);
      let token_name = to_string(&user_addr);
      let parts = AptogotchiParts {
          body,
          ear,
          face,
      };
      assert!(!has_aptogotchi(user_addr), error::already_exists(EUSER_ALREADY_HAS_APTOGOTCHI));

      let constructor_ref = token::create_named_token(
          &get_app_signer(),
          string::utf8(APTOGOTCHI_COLLECTION_NAME),
          description,
          token_name,
          option::none(),
          uri,
      );

      let token_signer = object::generate_signer(&constructor_ref);
      let mutator_ref = token::generate_mutator_ref(&constructor_ref);
      let burn_ref = token::generate_burn_ref(&constructor_ref);
      let transfer_ref = object::generate_transfer_ref(&constructor_ref);

      // initialize/set default Aptogotchi struct values
      let gotchi = Aptogotchi {
          name,
          birthday: timestamp::now_seconds(),
          energy_points: ENERGY_UPPER_BOUND,
          parts,
          mutator_ref,
          burn_ref,
      };

      move_to(&token_signer, gotchi);

      // Emit event for minting Aptogotchi token
      event::emit_event<MintAptogotchiEvent>(
          &mut borrow_global_mut<MintAptogotchiEvents>(@aptogotchi).mint_aptogotchi_events,
          MintAptogotchiEvent {
              token_name,
              aptogotchi_name: name,
              parts,
          },
      );

      object::transfer_with_ref(object::generate_linear_transfer_ref(&transfer_ref), address_of(user));
  }
  • Event

    さらにSolidityでも存在した Eventの概念もMove言語には存在します!!

    今回だと下記部分で利用されています。

        move_to(&token_signer, gotchi);
    
        // Emit event for minting Aptogotchi token
        event::emit<MintAptogotchiEvent>(
            MintAptogotchiEvent {
                token_name,
                aptogotchi_name: name,
                parts,
            },
        );
    

    使うためには専用のライブラリをインポートする必要があります。

    use aptos_framework::event;
    

    そして発火させたいEvent内容を定義します。

    // Event handler / container
    struct MintAptogotchiEvents has key {
        mint_aptogotchi_events: event::EventHandle<MintAptogotchiEvent>,
    }
    
    // Event's associated data
    // The general principle is to include all data necessary
    // to understand the changes to the underlying resources
    // before and after the execution of the transaction (that changed the data and emitted the event).
    struct MintAptogotchiEvent has drop, store {
        aptogotchi_name: String,
        birthday: u64,
        parts: vector<u8>,
    }
    

    この辺まではSolidityで実装していたときと感覚はほぼ同じですね!!

    次にEventを発火させる際の手順ですがまずイベントハンドラーを定義します。

    move_to(account, MintAptogotchiEvents {
        mint_aptogotchi_events: account::new_event_handle<MintAptogotchiEvent>(account),
    });
    

    そして発火させます!!

    event::emit_event<MintAptogotchiEvent>(
            &mut borrow_global_mut<MintAptogotchiEvents>(@aptogotchi).mint_aptogotchi_events,
            MintAptogotchiEvent {
                aptogotchi_name: name,
                parts,
            },
        );
    

スマートコントラクトのデプロイ

ソースコードの解説が1段落しましたので最後にスマートコントラクトをデプロイしてみます。

環境構築のところでビルドできていればmoveフォルダ配下で下記コマンドを実行するだけです!!

aptos move publish --named-addresses aptogotchi=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 aptogotchi
package size 6893 bytes
Do you want to submit a transaction for a range of [571000 - 856500] Octas at a gas unit price of 100 Octas? [yes/no] >
yes
{
  "Result": {
    "transaction_hash": "0x446aa8ebb3b96813b5dd0dab3c0b2b104741c24747c727684949f1279f0e97b4",
    "gas_used": 5710,
    "gas_unit_price": 100,
    "sender": "9324da379576a6210929ee2611cd1995a21451d0be44ec92a92d17cae1d665ee",
    "sequence_number": 0,
    "success": true,
    "timestamp_us": 1709992083293106,
    "version": 126115032,
    "vm_status": "Executed successfully"
  }
}

無事にDevnetにデプロイされました!!!

以下は、ブロックエクスプローラー上での情報です!!

https://explorer.aptoslabs.com/txn/0x446aa8ebb3b96813b5dd0dab3c0b2b104741c24747c727684949f1279f0e97b4?network=devnet

Aptos上のトランザクションデータは Aptos Explorerで確認することができます!!
EthereumでいうところのEtherScanに該当するツールですね!!

機能としては以下のものが実装されています。

機能名 概要
ネットワークの切り替え Devnet、Testnet、mainnetを切り替えられます。
アカウント情報の確認 アカウントの残高やトランザクション履歴を確認できます。
スマートコントラクトのコードの確認 デプロイされたスマートコントラクトのソースコードの確認ができます。
スマートコントラクトのメソッド実行 スマートコントラクトの機能を実行することができます。

Aptos Indexerを活用したデータ取得

AptosにはプロトコルレベルでIndexer機能が実装されています!!

EVM系のチェーンだと TheGraphなどを活用する必要がありましたよね?

あれが標準で使えるわけです!!!

別にAptosSDKを使ってもデータの読み取りはできるんですが、効率性やクエリの複雑さの点で限界があります。オンチェーンデータを読み取るためにより高性能な機能を求める時に役立つのが、Indexerです。

Aptos Indexerは未加工のブロックチェーンデータを受け取り、処理し、従来のデータベースを使用してよりクエリ可能な形式で保存して、GraphQL APIを通じて簡単にデータを照会することが可能です!

言葉だけだと分かりづらいので実際にコードを見ていきましょう!!

GraphQLってなに?? という方は下記のチュートリアルビデオをご覧ください!!

https://www.youtube.com/watch?v=eIQh02xuVw4

ネットワークごとのGraphQLエンドポイントの情報は下記に記載されています。

https://aptos.dev/indexer/api/labs-hosted/

今回のAptogochi用のエンドポイントのページは下記になります!!

https://cloud.hasura.io/public/graphiql?endpoint=https%3A%2F%2Findexer-testnet.staging.gcp.aptosdev.com%2Fv1%2Fgraphql&variables={"collection_id"%3A""}&query=query+AptogotchiCollectionQuery(%24collection_id%3A+String)+{ ++current_collections_v2(where%3A+{collection_id%3A+{_eq%3A+%24collection_id}})+{ ++++collection_id ++++collection_name ++++current_supply ++} ++current_collection_ownership_v2_view( ++++where%3A+{collection_id%3A+{_eq%3A+%24collection_id}} ++++order_by%3A+{last_transaction_version%3A+desc} ++)+{ ++++owner_address ++} }

サイトにアクセスできたら試しに下記のようなクエリを実行してみましょう!!

query AptogotchiCollectionQuery($collection_id: String) {
  current_collections_v2(where: {collection_id: {_eq: $collection_id}}) {
    collection_id
    collection_name
    current_supply
  }
  current_collection_ownership_v2_view(
    where: {collection_id: {_eq: $collection_id}}
    order_by: {last_transaction_version: desc}
  ) {
    owner_address
  }
  account_transactions(limit: 2) {
    account_address
  }
}

うまく処理されたら下記のような出力結果が得られるはずです!!

{
  "data": {
    "current_collections_v2": [],
    "current_collection_ownership_v2_view": [],
    "account_transactions": [
      {
        "account_address": "0x0000000000000000000000000000000000000000000000000000000000000001"
      },
      {
        "account_address": "0x0000000000000000000000000000000000000000000000000000000000000002"
      }
    ]
  }
}

実際にフロントエンドで使う時には以下のように実装します。

今回だとuseGetAptogochiCollection.tsファイルに実装されています。

import { useCallback, useState } from "react";
import { useWallet } from "@aptos-labs/wallet-adapter-react";
import { getAptosClient } from "@/utils/aptosClient";
import { NEXT_PUBLIC_CONTRACT_ADDRESS } from "@/utils/env";
import { queryAptogotchiCollection } from "@/graphql/queryAptogotchiCollection";
import { padAddressIfNeeded } from "@/utils/address";

const aptosClient = getAptosClient();

type Collection = {
  collection_id: string;
  collection_name: string;
  creator_address: string;
  uri: string;
  current_supply: any;
};

type CollectionHolder = {
  owner_address: string;
};

type CollectionResponse = {
  current_collections_v2: Collection[];
  current_collection_ownership_v2_view: CollectionHolder[];
};

export function useGetAptogotchiCollection() {
  const { account } = useWallet();
  const [collection, setCollection] = useState<Collection>();
  const [firstFewAptogotchiName, setFirstFewAptogotchiName] = useState<string[]>();
  const [loading, setLoading] = useState(false);

  const fetchCollection = useCallback(async () => {
    if (!account?.address) return;

    try {
      setLoading(true);

      const aptogotchiCollectionIDResponse = (await aptosClient.view({
        payload: {
          function: `${NEXT_PUBLIC_CONTRACT_ADDRESS}::main::get_aptogotchi_collection_id`,
          arguments: [],
        },
      })) as [`0x${string}`];

      const collectionIDAddr = padAddressIfNeeded(aptogotchiCollectionIDResponse[0]);

      const collectionResponse: CollectionResponse = await aptosClient.queryIndexer({
        query: {
          query: queryAptogotchiCollection,
          variables: {
            collection_id: collectionIDAddr,
          },
        },
      });

      const firstFewAptogotchi = await Promise.all(
        collectionResponse.current_collection_ownership_v2_view
          .filter((holder) => holder.owner_address !== account.address)
          // TODO: change to limit 3 in gql after indexer fix limit
          .slice(0, 3)
          .map((holder) =>
            aptosClient.view({
              payload: {
                function: `${NEXT_PUBLIC_CONTRACT_ADDRESS}::main::get_aptogotchi`,
                arguments: [holder.owner_address],
              },
            }),
          ),
      );

      setCollection(collectionResponse.current_collections_v2[0]);
      setFirstFewAptogotchiName(firstFewAptogotchi.map((x) => x[0] as string));
    } catch (error) {
      console.error("Error fetching Aptogotchi collection:", error);
    } finally {
      setLoading(false);
    }
  }, [account?.address]);

  return { collection, firstFewAptogotchiName, loading, fetchCollection };
}

Move言語によるユニットテスト

さて次に実装したスマートコントラクトのテストコードを実装する方法を学んでいきます!!

NearやSolana向けのスマートコントラクトを実装したことがある人ならわかると思いますが、Rust系の言語で実装するスマートコントラクトの場合はテストコードは一緒のファイルに実装してしまうことが多いです。

今回だと次のようなテストコードがデフォルトで用意されています。

// ==== TESTS ====
    // Setup testing environment
    #[test_only]
    use aptos_framework::account::create_account_for_test;
    #[test_only]
    use std::string::utf8;

    #[test_only]
    fun setup_test(aptos: &signer, account: &signer, creator: &signer) {
        // create a fake account (only for testing purposes)
        create_account_for_test(signer::address_of(creator));
        create_account_for_test(signer::address_of(account));

        timestamp::set_time_has_started_for_testing(aptos);
        init_module(account);
    }

    // Test creating an Aptogotchi
    #[test(aptos = @0x1, account = @aptogotchi, creator = @0x123)]
    fun test_create_aptogotchi(
        aptos: &signer,
        account: &signer,
        creator: &signer
    ) acquires ObjectController {
        setup_test(aptos, account, creator);

        create_aptogotchi(creator, utf8(b"test"), 1, 1, 1);

        let has_aptogotchi = has_aptogotchi(signer::address_of(creator));
        assert!(has_aptogotchi, 1);
    }

    // Test getting an Aptogotchi, when user has not minted
    #[test(aptos = @0x1, account = @aptogotchi, creator = @0x123)]
    #[expected_failure(abort_code = 851969, location = aptogotchi::main)]
    fun test_get_aptogotchi_without_creation(
        aptos: &signer,
        account: &signer,
        creator: &signer
    ) acquires Aptogotchi {
        setup_test(aptos, account, creator);

        // get aptogotchi without creating it
        get_aptogotchi(signer::address_of(creator));
    }

    // Test getting an Aptogotchi, when user has not minted
    #[test(aptos = @0x1, account = @aptogotchi, creator = @0x123)]
    fun test_feed_and_play(
        aptos: &signer,
        account: &signer,
        creator: &signer
    ) acquires ObjectController, Aptogotchi {
        setup_test(aptos, account, creator);
        let creator_address = signer::address_of(creator);
        create_aptogotchi(creator, utf8(b"test"), 1, 1, 1);

        let (_, _, energe_point_1, _) = get_aptogotchi(creator_address);
        assert!(energe_point_1 == ENERGY_UPPER_BOUND, 1);

        play(creator, 5);
        let (_, _, energe_point_2, _) = get_aptogotchi(creator_address);
        assert!(energe_point_2 == ENERGY_UPPER_BOUND - 5, 1);

        feed(creator, 3);
        let (_, _, energe_point_3, _) = get_aptogotchi(creator_address);
        assert!(energe_point_3 == ENERGY_UPPER_BOUND - 2, 1);
    }

    // Test getting an Aptogotchi, when user has not minted
    #[test(aptos = @0x1, account = @aptogotchi, creator = @0x123)]
    #[expected_failure(abort_code = 524291, location = aptogotchi::main)]
    fun test_create_aptogotchi_twice(
        aptos: &signer,
        account: &signer,
        creator: &signer
    ) acquires ObjectController {
        setup_test(aptos, account, creator);

        create_aptogotchi(creator, utf8(b"test"), 1, 1, 1);
        create_aptogotchi(creator, utf8(b"test"), 1, 1, 1);
    }

前半部分はユニットテストを行う前のセットアップになっています。hardhatなどで実装するときと同じですね。

テストコードが書けたら次のコマンドでテストを実行します!!!

aptos move test

テストを行う時はMove.tomlファイルがあるフォルダまで移動する必要があります。

今回だと以下のような出力結果になれば成功です!!

INCLUDING DEPENDENCY AptosFramework
INCLUDING DEPENDENCY AptosStdlib
INCLUDING DEPENDENCY AptosTokenObjects
INCLUDING DEPENDENCY MoveStdlib
BUILDING aptogotchi
Running Move unit tests
[ PASS    ] 0x9324da379576a6210929ee2611cd1995a21451d0be44ec92a92d17cae1d665ee::main::test_create_aptogotchi
[ PASS    ] 0x9324da379576a6210929ee2611cd1995a21451d0be44ec92a92d17cae1d665ee::main::test_create_aptogotchi_twice
[ PASS    ] 0x9324da379576a6210929ee2611cd1995a21451d0be44ec92a92d17cae1d665ee::main::test_feed_and_play
[ PASS    ] 0x9324da379576a6210929ee2611cd1995a21451d0be44ec92a92d17cae1d665ee::main::test_get_aptogotchi_without_creation
Test result: OK. Total tests: 4; passed: 4; failed: 0
{
  "Result": "Success"
}

ANS(Aptos Names Service)

EthereumでいうところのENSですね!!
AptosにはANSという名前解決のためのプロトコルが存在します!!!

https://www.aptosnames.com/

長くなりましたが今回はここまでにしたいと思います!!

皆さん、ありがとうございました!!!

Discussion