SoulBound TokenをQiitaのプロフィールから作ってみた

お知らせ
Table of Contents

フロントエンドチーム小林です。今回は、Qiitaのプロフィール情報からSoulBound Tokenを作ってみます。

SoulBound Tokenとは

Vitalikにより提唱された概念で、譲渡が不可能なNFTのことです。参加証明で配布されているPOAPなどと相性が良く、今後利用される場面が増えてくることが期待されています。
https://vitalik.ca/general/2022/01/26/soulbound.html

・POAPの例

作るもの

Qiitaのプロフィール情報を用いてSBTを作成します。

作り方

https://3tomcha.github.io/QiitaSBT/
Qiitaのユーザーidを入力します。

ユーザーが見つかるとプロフィール情報が表示されます。
Transformボタンをクリックします

Metamaskが反応します。
確認ボタンをクリックします。

成功するとメッセージが表示されます。
リンクをクリックするとEtherscanへとびます

Dialogを閉じると、コントラクトアドレスとトークンIDを見ることができます。
それらを、SP版のMetamaskのNFTをインポートから入力するとNFTを見ることができます。

TestNet版Openseaに、SP版のMetamaskを接続すると、詳細な属性値を見ることができます
https://testnets.opensea.io/ja

生成したNFTは、SoulBoundTokenのため、Metamaskから他のアカウントに送ろうとするとエラーになります。

コントラクトの実装

Contractは下記のように実装します。burnと_beforeTokenTransferがSoulBound Token独自の処理で、burnやtransferに制限をかけています。

// contracts/MyNFT.sol
pragma solidity >=0.7.0 <0.9.0;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";

contract MyNFT is Ownable, ERC721URIStorage {
    using Counters for Counters.Counter;
    Counters.Counter private _tokenIds;

    constructor(string memory name, string memory symbol)
        ERC721(name, symbol)
    {}

    function currentTokenId() public view returns (uint256) {
        return _tokenIds.current();
    }

    function mintNFT(address recipient, string memory tokenURI) public {
        _tokenIds.increment();

        uint256 newItemId = _tokenIds.current();
        _mint(recipient, newItemId);
        _setTokenURI(newItemId, tokenURI);
    }

    function burn(uint256 tokenId) external {
        require(
            ownerOf(tokenId) == msg.sender,
            "Only the owner of the token can burn it."
        );
        _burn(tokenId);
    }

    function _beforeTokenTransfer(
        address from,
        address to,
        uint256
    ) internal pure override {
        require(
            from == address(0) || to == address(0),
            "This is a Soulbound token. It cannot be transferred. It can only be burned by the token owner."
        );
    }

    function _burn(uint256 tokenId) internal override(ERC721URIStorage) {
        super._burn(tokenId);
    }
}

フロントエンドの実装

全体の処理

Submitボタンをクリックすると、Qiitaのプロフィール情報を取得して表示します。Transferボタンをクリックすると、Qiitaのプロフィール情報をもとに*IPFSにアップロードします。その後Metamaskへ接続、Mintし結果を表示します。詳細な処理は、他のファイルに記述しており、読み込んで使用します。
※ IPFS:ファイルを分散して保存できる仕組み

// vue-project/src/App.vue
<script setup lang="ts">
import useQiita from "@/composable/use-qiita";
import { ref, computed } from "vue";
import useIpfs from "@/composable/use-ipfs";
import useMetaData from "@/composable/use-metadata";
import useProvider from "@/composable/use-provider";
import { ElMessage } from "element-plus";
import type { TxReceipt } from "@/types/types";
import { contractAddress } from "@/const/contract";

const { qiitaProfile, getProfile } = useQiita();
const { ipfsUrl, pinJSONToIPFS } = useIpfs();
const { formatToMetaData } = useMetaData();
const { init, connectMetamask, mintNFT, switchEthereumChain, currentTokenId } = useProvider();

const input = ref<string>();
const dialogVisible = ref<Boolean>();
const txReceipt = ref<TxReceipt>();

const getQiitaProfile = async () => {
  const userName = input.value;
  if (!userName) {
    alert("ユーザー名が入力されていません。");
    return;
  }
  await getProfile(userName).catch(err => {
    console.error(err);
  });
};

const uploadToIpfs = async () => {
  if (qiitaProfile.value) {
    const metaData = formatToMetaData(qiitaProfile.value);
    console.log(metaData);
    const res = await pinJSONToIPFS(metaData).catch(err => {
      console.error(err);
    });
  }
}

const connectAndMint = async () => {
  const success = init();
  if (!success) {
    return ElMessage.error("Metamaskをインストールしてください");
  }
  await connectMetamask();
  await switchEthereumChain();
  const receipt = await mintNFT(ipfsUrl.value);
  if (receipt) {
    console.log(receipt);
    dialogVisible.value = true;
    txReceipt.value = receipt;
  }
}

const txExplorerURL = computed(() =>
  txReceipt.value ? `https://goerli.etherscan.io/tx/${txReceipt.value.hash} ` : ""
)

const submit = async () => {
  await uploadToIpfs();
  await connectAndMint();
}
</script>

<template>
  <main>
    <h1><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 426.57 130" fill="white">
        <circle cx="167.08" cy="21.4" r="12.28"></circle>
        <path d="M250.81 29.66h23.48v18.9h-23.48z"></path>
        <path
          d="M300.76 105.26a22.23 22.23 0 01-6.26-.86 12.68 12.68 0 01-5.17-3 14.41 14.41 0 01-3.56-5.76 28 28 0 01-1.3-9.22V48.56h29.61v-18.9h-29.52V3.29h-20.17v83.34q0 11.16 2.83 18.27a27.71 27.71 0 007.7 11.2 26.86 26.86 0 0011.43 5.62 47.56 47.56 0 0012.34 1.53h15.16v-18zM0 61.7a58.6 58.6 0 015-24.21A62.26 62.26 0 0118.73 17.9 63.72 63.72 0 0139 4.78 64.93 64.93 0 0164 0a65 65 0 0124.85 4.78 64.24 64.24 0 0120.38 13.12A62 62 0 01123 37.49a58.6 58.6 0 015 24.21 58.34 58.34 0 01-4 21.46 62.8 62.8 0 01-10.91 18.16l11.1 11.1a10.3 10.3 0 010 14.52 10.29 10.29 0 01-14.64 0l-12.22-12.41a65 65 0 01-15.78 6.65 66.32 66.32 0 01-17.55 2.3 64.63 64.63 0 01-45.23-18A62.82 62.82 0 015 85.81 58.3 58.3 0 010 61.7zm21.64.08a43.13 43.13 0 0012.42 30.63 42.23 42.23 0 0013.43 9.09A41.31 41.31 0 0064 104.8a42 42 0 0030-12.39 42.37 42.37 0 009-13.64 43.43 43.43 0 003.3-17 43.77 43.77 0 00-3.3-17A41.7 41.7 0 0080.55 22 41.78 41.78 0 0064 18.68 41.31 41.31 0 0047.49 22a42.37 42.37 0 00-13.43 9.08 43.37 43.37 0 00-12.42 30.7zM331.89 78a47.59 47.59 0 013.3-17.73 43.22 43.22 0 019.34-14.47A44.25 44.25 0 01359 36a47.82 47.82 0 0118.81-3.58 42.72 42.72 0 019.26 1 46.5 46.5 0 018.22 2.58 40 40 0 017 3.84 44.39 44.39 0 015.71 4.63l1.22-9.47h17.35v85.83h-17.35l-1.17-9.42a42.54 42.54 0 01-5.84 4.67 43.11 43.11 0 01-7 3.79 44.86 44.86 0 01-8.17 2.59 43 43 0 01-9.22 1A47.94 47.94 0 01359 119.9a43.3 43.3 0 01-14.47-9.71 44.17 44.17 0 01-9.34-14.47 47 47 0 01-3.3-17.72zm20.27-.08a29.16 29.16 0 002.17 11.34 27 27 0 005.92 8.88 26.69 26.69 0 008.76 5.76 29.19 29.19 0 0021.44 0 26.11 26.11 0 008.72-5.76 27.57 27.57 0 005.88-8.84 29 29 0 002.16-11.38 28.62 28.62 0 00-2.16-11.22 26.57 26.57 0 00-5.93-8.8 27.68 27.68 0 00-19.51-7.9 28.29 28.29 0 00-10.77 2.05 26.19 26.19 0 00-8.71 5.75 27.08 27.08 0 00-5.84 8.8 28.94 28.94 0 00-2.13 11.31zm-194.97-30.5h19.78v73.54h-19.78zm49.25 0h19.78v73.54h-19.78z">
        </path>
        <circle cx="216.33" cy="21.4" r="12.28"></circle>
      </svg>をNFTに変換</h1>
    <article>
      <h2>Qiitaのプロフィール情報をもとにNFTを作成します。</h2>
    </article>
    <input type="text" placeholder="Qiitaのユーザーid" v-model="input" />
    <el-button type="info" class="submit" plain @click="getQiitaProfile">Submit</el-button>
    <div v-if="qiitaProfile" class="profile">
      <p>ユーザーが見つかりました</p>
      <textarea disabled class="result">
      {{ qiitaProfile }}
    </textarea>
    </div>
    <el-button type="danger" class="transform" @click="submit" v-if="qiitaProfile">Transform</el-button>
    <template v-if="txExplorerURL">
      <p style="margin-top: 24px">トランザクション</p>
      <a :href="txExplorerURL">{{ txExplorerURL }}</a>
      <p style="margin-top: 24px">
        SP版のMetamaskのNFTをインポートから下記の内容を入力すると、トークンをみることができます
      </p>
      <textarea disabled class="result">
アドレス:
{{ contractAddress }}

ID:
{{ Number(currentTokenId) + 1 }} 
      </textarea>
    </template>
  </main>
  <el-dialog v-model="dialogVisible" title="Succeeded" width="96%">
    <span>NFTに変換できました</span>
    <p>{{ txReceipt }}</p>
    <p style="margin-top: 16px">トランザクション</p>
    <a :href="txExplorerURL">{{ txExplorerURL }}</a>
    <template #footer>
      <span class="dialog-footer">
        <el-button type="primary" @click="dialogVisible = false">
          閉じる
        </el-button>
      </span>
    </template>
  </el-dialog>
</template>

<style lang="scss">
html {
  background-color: #55c500;
}

p {
  margin: 0;
}

main {
  padding: 2%;
  display: flex;
  flex-flow: column;
  min-height: 80vh;
  justify-content: flex-start;
  margin-left: auto;
  margin-right: auto;
  row-gap: 8px;

  svg {
    height: 46px;
  }

  h1 {
    display: flex;
    align-items: baseline;
    font-size: 26px;
    column-gap: 10px;
    margin-left: auto;
    margin-right: auto;
  }

  h2 {
    font-size: 20px;
    color: white;
  }

  input {
    font-family: Helvetica, Arial, sans-serif;
    font-weight: 500;
    font-size: 18px;
    border-radius: 5px;
    line-height: 22px;
    border: 2px solid white;
    transition: all 0.3s;
    padding: 13px;
    width: 100%;
    box-sizing: border-box;
    outline: 0;
  }

  .submit {
    text-align: center;
    font-size: 26px;
    font-weight: bold;
    font-family: Helvetica, Arial, sans-serif;
    width: 300px;
    margin-left: auto;
    margin-right: auto;
    border-radius: 8px;
    height: 2em;
    margin-top: 8px;
    border: 3px solid;
  }

  .transform {
    text-align: center;
    font-size: 26px;
    font-weight: bold;
    font-family: Helvetica, Arial, sans-serif;
    width: 300px;
    margin-left: auto;
    margin-right: auto;
    border-radius: 8px;
    height: 2em;
    margin-top: 8px;
  }

  textarea {
    height: 35em;
    background: white;
    border-radius: 8px;
    width: 100%;
    box-sizing: border-box;
  }

  input,
  textarea {
    -webkit-appearance: none;
  }

  .profile {
    margin-top: 20px;
  }

  @media screen and (min-width: 1300px) {
    width: 1080px;
  }

  a {
    word-wrap: break-word;
    white-space: pre-wrap;
  }

  .result {
    background: white;
    margin-top: 16px;
  }

  textarea:disabled {
    background: white !important;
  }

  input,
  textarea {
    opacity: 1 !important;
  }

  .success {
    color: white;
  }
}
</style>

Qiitaのプロフィール情報を取得する

QiitaAPIは使いやすく、プロフィール情報は下記のように簡単に取得できます。

https://qiita.com/api/v2/users/${ユーザー名}
// vue-project/src/composable/use-qiita.ts
import { ref } from "vue";
import type { QiitaMetaData } from "@/types/types";

export default function useQiita() {
    const Qiita_API_URL = "https://qiita.com/api/v2/";
    const qiitaProfile = ref<QiitaMetaData>();

    const getProfile = async (userName: string) => {
        const url = Qiita_API_URL + "users/" + userName;
        const res = await fetch(url).then(response => {
            if (!response.ok) {
                console.error('response.ok:', response.ok);
                console.error('esponse.status:', response.status);
                console.error('esponse.statusText:', response.statusText);
                alert("ユーザーが見つかりませんでした");
                throw new Error(response.statusText);
            }
            return response.json()
        }).catch(err => {
            console.error(err);
        });
        if (res) {
            console.log(res);
            qiitaProfile.value = res;
        }
    }
    return {
        qiitaProfile,
        getProfile
    }
}

QiitaのMetadataの型をOpenseaのMetadataの型に変換する

QiitaAPIから返ってくる情報は、NFTのMetadataとして解釈してあげる必要があります。今回は、Openseaにも表示したいので、下記のスタンダードに則った形にします。
https://docs.opensea.io/docs/metadata-standards

descriptionやnameなどはそのままでいいですが、locationやorganizationなどのMetadataの項目に対応していないものは、attributesの配列の中に入れます。

// vue-project/src/composable/use-metadata.ts
export default function useMetaData() {
    type MetaData = {
        description: String,
        image: String,
        name: String,
        attributes: {
            display_type?: String,
            trait_type: String,
            value: String | Number
        }[]
    }
    type QiitaMetaData = {
        description: String
        facebook_id: String,
        followees_count: Number,
        followers_count: Number,
        github_login_name: String,
        id: String,
        items_count: Number,
        linkedin_id: String,
        location: String,
        name: String,
        organization: String,
        permanent_id: Number,
        profile_image_url: String,
        team_only: Boolean,
        twitter_screen_name: String,
        website_url: String
    }
    const formatToMetaData = (json: QiitaMetaData) => {
        const metaData: MetaData = {
            description: json.description,
            image: json.profile_image_url,
            name: json.name,
            attributes: []
        }
        for (const [key, value] of Object.entries(json)) {
            if (value && typeof value === 'string') {
                const stringAttributes = {
                    trait_type: key,
                    value: value
                }
                metaData.attributes.push(stringAttributes);
            } else if (value && typeof value === 'number') {
                const numbreAttributes = {
                    display_type: "number",
                    trait_type: key,
                    value: value
                }
                metaData.attributes.push(numbreAttributes);
            }
        }
        return metaData;
    };

    return {
        formatToMetaData
    }
}

JSONデータをIPFSにアップする

PinataAPIを通して、IPFSにアップします。
https://www.pinata.cloud/
IPFSに直接アップすることもできますが、自分でIPFSノードを立てる必要があり、手間と運用コストが発生します。その点、Pinataを使うとAPIを使うだけですみ、便利です。"pinataContent"の箇所に、保存したいデータを入れ、下記URLに、POSTで送信するとIPFSにアップできます。
https://api.pinata.cloud/pinning/pinJSONToIPFS

// vue-project/src/composable/use-ipfs.ts
import axios from "axios";
import { ref } from "vue";

export default function useIpfs() {
    const ipfsUrl = ref<string>("");

    const pinJSONToIPFS = async (content: Object) => {
        const data = JSON.stringify({
            "pinataOptions": {
                "cidVersion": 1
            },
            "pinataMetadata": {
                "name": "testing",
                "keyvalues": {
                    "customKey": "customValue",
                    "customKey2": "customValue2"
                }
            },
            "pinataContent": content
        });

        const config = {
            method: 'post',
            url: 'https://api.pinata.cloud/pinning/pinJSONToIPFS',
            headers: {
                'Content-Type': 'application/json',
                "pinata_api_key": import.meta.env.VITE_PINATA_API_KEY,
                "pinata_secret_api_key": import.meta.env.VITE_PINATA_SECRET_API_KEY,
            },
            data: data
        };
        const res: any = await axios(config).catch(err => {
            console.error(err);
        });
        if (res) {
            console.log(res);
            ipfsUrl.value = "ipfs://" + res.data.IpfsHash
        }
    }

    return {
        ipfsUrl,
        pinJSONToIPFS
    }
}

Metamaskに接続しMintする

Metamask・Goerliネットワークを使って、スマートコントラクトの処理を行います。
Metamaskをインストールしているかどうかは、window.ethereumがあるかどうかで判断します。また、今回は、Goerliネットワークを使っているので、もし別のネットワークに繋がっていたら自動で切り替えるようになっています。Metamaskに接続したら、NFTの受け取りアドレス(今接続しているユーザーのアドレス)、TokenURIを指定してMintを行います。TokenURIには、https ではなく、ipfs から始まるIPFS schemeを指定したURIを使います。

import { ref } from "vue";
import { ElMessage } from "element-plus";
import { ethers } from "ethers";
import { contractAddress } from "@/const/contract";
import abi from "@/abi/contract_abi.json";
import type { Contract_abi } from "@/types/ethers-contracts/Contract_abi";

export default function useProvider() {
  const accounts = ref<any>();
  const ethereum = ref<any>();
  const currentTokenId = ref<number>();

  const init = () => {
    if ((window as any).ethereum) {
      ethereum.value = (window as any).ethereum;
      return true;
    } else {
      ElMessage.error("Metamaskをインストールしてください");
      return false;
    }
  }

  const connectMetamask = async () => {
    const res = await ethereum.value.request({ method: "eth_requestAccounts" });
    if (res) {
      accounts.value = res;
    }
  }

  const switchEthereumChain = async () => {
    try {
      await ethereum.value.request({
        method: 'wallet_switchEthereumChain',
        params: [{ chainId: "0x5" }]
      })
    } catch (switchError) {
      console.log(switchError);
    }
  }

  const mintNFT = async (tokenUri: string) => {
    const provider = new ethers.providers.Web3Provider(ethereum.value);
    const signer = await provider.getSigner();
    const contract = await new ethers.Contract(contractAddress, abi, signer) as Contract_abi;
    const recepientAddress = accounts.value[0];
    const receipt = await contract.mintNFT(recepientAddress, tokenUri).catch(err => {
      console.error(err);
    })
    await getCurrentTokenId(contract);
    return receipt;
  }

  const getCurrentTokenId = async (contract: Contract_abi) => {
    const res = await contract.currentTokenId().catch(err => {
      console.error(err);
    });
    if (res) {
      currentTokenId.value = res.toNumber();
    }
  }

  return {
    ethereum,
    init,
    connectMetamask,
    mintNFT,
    switchEthereumChain,
    currentTokenId
  }
}

まとめ

今回は、Qiitaのプロフィール情報からSoulBound Tokenを作ってみました。身近な情報でもSoulBound Tokenとして持つと愛着がわきますね。また、SoulBound Tokenは、Comunity RecoveryやProgramable Privacyなどのさまざまな要素も提言されています。それらを組み込んでみると、より深い理解に繋がると思います。

タイトルとURLをコピーしました