Open8

特定のNFTを持っている人だけ見られるページを作る

YuheiNakasakaYuheiNakasaka

特定のNFTを持っている人だけ見られるページを作りたい。

実装

  • バックエンド
    • NFTコントラクト
      • 限定させたいのでERC-1155でNFTの個数制限かける
      • Wallet AddressからNFTの保有状況を検索できるインターフェースを追加
  • フロントエンド
    • Walletログイン
    • 取得したWallet AddressからコントラクトのABI経由でメソッドコール
    • NFTの保有者だったらページを表示、そうでなければ404
YuheiNakasakaYuheiNakasaka

自作したERC721のテンプレをERC1155用に改造する。
https://github.com/YuheiNakasaka/minimal-nft-ts

ひとまずは動かす。

$ git clone git@github.com:YuheiNakasaka/minimal-nft-ts.git minimal-nft-1155-ts
$ yarn
$ cd contracts
$ yarn dev:node
$ yarn dev:deploy
$ yarn dev:view
yarn run v1.22.17
hardhat run scripts/view.ts --network localhost
No need to generate any newer typings.
0
✨  Done in 9.36s.

よし。

YuheiNakasakaYuheiNakasaka

ERC721をERC1155に改造してみる。下記のOpenZeppelinのドキュメントを読めば実装に関しては十分そう。
https://docs.openzeppelin.com/contracts/4.x/erc1155

まずは特にカスタムせずERC1155PresetMinterPauserでコントラクトを作成してみる。

// contracts/NFT.ts
//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.0;

import "hardhat/console.sol";
import "@openzeppelin/contracts/token/ERC1155/presets/ERC1155PresetMinterPauser.sol";

contract NFT is ERC1155PresetMinterPauser {
  constructor() ERC1155PresetMinterPauser("http://localhost:3000/{id}.json") {}
}

一旦nodeを落としてから再度起動しデプロイ。

$ yarn dev:deploy

mintしてみる。ERC721と違ってmint(from, id, amount, data)と指定する引数が増えてる。TOKEN_ID:1のトークンを10個だけPUBLIC_KEYのアドレスに対してmintしてる。気をつけないといけないのはmintの4爪の引数はArrayだということ。byte型なので空文字で""とか指定してるとエラーが出る。

// scripts/mint.ts
import Web3 from "web3";

// ADDRESS, KEY and URL are examples.
const CONTRACT_ADDRESS = "0x5FbDB2315678afecb367f032d93F642f64180aa3";
const PUBLIC_KEY = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266";
const PROVIDER_URL = "http://localhost:8545";
const TOKEN_ID = 1;

async function mintNFT() {
  const web3 = new Web3(PROVIDER_URL);
  const contract = require("../artifacts/contracts/NFT.sol/NFT.json");
  const nftContract = new web3.eth.Contract(contract.abi, CONTRACT_ADDRESS);
  const nonce = await web3.eth.getTransactionCount(PUBLIC_KEY, "latest");
  const tx = {
    from: PUBLIC_KEY,
    to: CONTRACT_ADDRESS,
    nonce: nonce,
    gas: 500000,
    // Mint id:1 token which is amount of 10 to PUBLIC_KEY.
    data: nftContract.methods.mint(PUBLIC_KEY, TOKEN_ID, 10, []).encodeABI(),
  };

  const signPromise = web3.eth.accounts.signTransaction(
    tx,
    // Test account's fixed private key in hardhat local node
    "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
  );
  signPromise
    .then((signedTx) => {
      const tx = signedTx.rawTransaction;
      if (tx !== undefined) {
        web3.eth.sendSignedTransaction(tx, function (err, hash) {
          if (!err) {
            console.log("The hash of your transaction is: ", hash);
          } else {
            console.log(
              "Something went wrong when submitting your transaction:",
              err
            );
          }
        });
      }
    })
    .catch((err) => {
      console.log("Promise failed:", err);
    });
}

mintNFT();

実際にmintされたかを確認するスクリプト。

// scripts/view.ts
import Web3 from "web3";

const CONTRACT_ADDRESS = "0x5FbDB2315678afecb367f032d93F642f64180aa3";
const PUBLIC_KEY = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266";
const TOKEN_ID = 1;

async function viewNFT() {
  const web3 = new Web3("http://localhost:8545");
  const contract = require("../artifacts/contracts/NFT.sol/NFT.json");
  const nftContract = new web3.eth.Contract(contract.abi, CONTRACT_ADDRESS);
  // Display the PUBLIC_KEY's balance of id:1 token.
  nftContract.methods.balanceOf(PUBLIC_KEY, TOKEN_ID).call().then(console.log);
}

viewNFT();

実行してみる。

$ yarn dev:view 
yarn run v1.22.17
hardhat run scripts/view.ts --network localhost
No need to generate any newer typings.
10

よし。

YuheiNakasakaYuheiNakasaka

AdminのPUBLIC_KEYで持ってるトークン(id:1のやつ)を別の人に分け与えるスクリプトも書いてみる。safeTransferFromを使えば良い。

// scripts/transfer.ts
import Web3 from "web3";

// ADDRESS, KEY and URL are examples.
const CONTRACT_ADDRESS = "0x5FbDB2315678afecb367f032d93F642f64180aa3";
const PUBLIC_KEY = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266";
const PROVIDER_URL = "http://localhost:8545";
const TOKEN_ID = 1;
const RECEPIENT_ADDRESS = "0x70997970c51812dc3a010c7d01b50e0d17dc79c8";

async function transferNFT() {
  const web3 = new Web3(PROVIDER_URL);
  const contract = require("../artifacts/contracts/NFT.sol/NFT.json");
  const nftContract = new web3.eth.Contract(contract.abi, CONTRACT_ADDRESS);
  const nonce = await web3.eth.getTransactionCount(PUBLIC_KEY, "latest");
  const tx = {
    from: PUBLIC_KEY,
    to: CONTRACT_ADDRESS,
    nonce: nonce,
    gas: 500000,
    // Transfer three of PUBLIC_KEY's id:1 token to RECEPIENT_ADDRESS.
    data: nftContract.methods
      .safeTransferFrom(PUBLIC_KEY, RECEPIENT_ADDRESS, TOKEN_ID, 1, [])
      .encodeABI(),
  };

  const signPromise = web3.eth.accounts.signTransaction(
    tx,
    // Test account's fixed private key in hardhat local node
    "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
  );
  signPromise
    .then((signedTx) => {
      const tx = signedTx.rawTransaction;
      if (tx !== undefined) {
        web3.eth.sendSignedTransaction(tx, function (err, hash) {
          if (!err) {
            console.log("The hash of your transaction is: ", hash);
          } else {
            console.log(
              "Something went wrong when submitting your transaction:",
              err
            );
          }
        });
      }
    })
    .catch((err) => {
      console.log("Promise failed:", err);
    });
}

transferNFT();

実行する。

$ yarn dev:transfer

発行されてるかを確認する。さっきのscripts/view.tsを改造してRECEPIENT_ADDRESSのbalanceも表示する。

$ yarn dev:view
yarn run v1.22.17
hardhat run scripts/view.ts --network localhost
No need to generate any newer typings.
9
1

大丈夫そう。

YuheiNakasakaYuheiNakasaka

フロントエンドを作成する。まずはwalletログイン。usedappを使う。

$ yarn add @usedapp/core ethers

usedappの公式Docsに倣ってconnectボタンを設置する。
https://usedapp.readthedocs.io/en/latest/getting-started.html

import { Head, BlitzPage } from "blitz"
import Layout from "app/core/layouts/Layout"
import { ChainId, DAppProvider, useEthers, Config } from "@usedapp/core"
import { Box, Flex, Text } from "@chakra-ui/layout"
import { Button } from "@chakra-ui/button"

const config: Config = {
  readOnlyChainId: ChainId.Localhost,
  readOnlyUrls: {
    [ChainId.Localhost]: "http://localhost:8545",
  },
}

const MainContent = () => {
  const { activateBrowserWallet, account } = useEthers()
  return (
    <>
      <Flex alignItems="center" justifyContent="center" w="100vw" minH="100vh">
        <Box textAlign="center">
          <Box my="1rem">
            <Text fontSize="lg" fontWeight="600">
              Status
            </Text>
            {account ? <Text>Connected</Text> : <Text>Not connected</Text>}
          </Box>
          {!account && (
            <Button
              onClick={() => {
                activateBrowserWallet()
              }}
            >
              Connect Wallet!
            </Button>
          )}
        </Box>
      </Flex>
    </>
  )
}

const NFTLoginPage: BlitzPage = () => {
  return (
    <>
      <Head>
        <title>NFTLogin</title>
      </Head>
      <DAppProvider config={config}>
        <MainContent />
      </DAppProvider>
    </>
  )
}

NFTLoginPage.authenticate = false
NFTLoginPage.getLayout = (page) => <Layout>{page}</Layout>

export default NFTLoginPage

実際にログインしてみる。ローカルで動かすのでノードを立ち上げておくのを忘れない。

yarn dev:node

ボタンを押すとMetaMaskが立ち上がり許可するとStatusがConnectedになった。よし。

YuheiNakasakaYuheiNakasaka

実際にNFTを保持しているか確認するhooksを書く。Contractのコードをコンパイルしてartifact配下に生成されたABIファイル(json)を適当なディレクトリに設置しておく。

import { useEthers } from "@usedapp/core"
import { utils, Contract } from "ethers"
import ABI from "../resources/nft-login-abi.json"

const TOKEN_ID = 1
const CONTRACT_ADDRESS = "0x5FbDB2315678afecb367f032d93F642f64180aa3"

export const useCallNFTLogin = () => {
  const { library } = useEthers()
  const inteface = new utils.Interface(ABI.abi)
  const contract = new Contract(CONTRACT_ADDRESS, inteface, library?.getSigner())
  return async (address: string): Promise<boolean> => {
    const balance = await contract.balanceOf(address, TOKEN_ID)
    return parseInt(balance.toString()) > 0 // NFTを持っているとtrueを返す
  }
}

まずはNFTの発行

yarn dev:deploy
yarn dev:mint

次にNFTを自分のMetamaskのwalletアドレスに送る。さっき書いたtransfer.tsRECEPIENT_ADDRESSを自分のアドレスに変えればおけ。

yarn dev:transfer

これで準備完了。UI側にtokenを持っている人だけが見られるテキストを表示させる。うまくいけばYou are a special user!という文字列が表示されるはず。

import { Head, BlitzPage } from "blitz"
import Layout from "app/core/layouts/Layout"
import { ChainId, DAppProvider, useEthers, Config } from "@usedapp/core"
import { Box, Flex, Text } from "@chakra-ui/layout"
import { Button } from "@chakra-ui/button"
import { useCallNFTLogin } from "app/playgrounds/hooks/useCallNFTLogin"
import { useState } from "react"

const config: Config = {
  readOnlyChainId: ChainId.Localhost,
  readOnlyUrls: {
    [ChainId.Localhost]: "http://localhost:8545",
  },
}

const MainContent = () => {
  const [authenticated, setAuthenticated] = useState(false)
  const { activateBrowserWallet, account } = useEthers()
  const hasValidNFT = useCallNFTLogin()
  return (
    <>
      <Flex alignItems="center" justifyContent="center" w="100vw" minH="100vh">
        <Box textAlign="center">
          <Box my="1rem">
            <Text fontSize="lg" fontWeight="600">
              Status
            </Text>
            {account ? <Text>Connected</Text> : <Text>Not connected</Text>}
          </Box>
          {!account && (
            <Button
              onClick={() => {
                activateBrowserWallet()
              }}
            >
              Connect Wallet!
            </Button>
          )}
          {account && (
            <Box>
              <Button
                onClick={async () => {
                  setAuthenticated(await hasValidNFT(account))
                }}
              >
                Show the limited contents?
              </Button>
            </Box>
          )}
          {account && authenticated && (
            <Box mt="1rem">
              <Text fontSize="2xl" fontWeight="600">
                You are a special user!
              </Text>
            </Box>
          )}
        </Box>
      </Flex>
    </>
  )
}

const NFTLoginPage: BlitzPage = () => {
  return (
    <>
      <Head>
        <title>NFTLogin</title>
      </Head>
      <DAppProvider config={config}>
        <MainContent />
      </DAppProvider>
    </>
  )
}

NFTLoginPage.authenticate = false
NFTLoginPage.getLayout = (page) => <Layout>{page}</Layout>

export default NFTLoginPage

出来た!これを応用すれば特定のNFTを持っている人だけ見られるコンテンツを表示するとかが出来そう。

おわり!