🤠

Next.js、ReactJSエンジニアのための今使えるWeb3の話

2022/12/27に公開

この記事はWeb3 Dev Advent Calendar 2022 - Qiitaの25日目の記事です。

はじめに

この記事では、Next.js(ReactJS)のエンジニアの皆さんに、Web3(ブロックチェーン)とは何かを具体的に説明します。つまり、分散台帳がどうのこうのといったモヤっとした話はいっさいしません。

Web3アプリ

現実問題としてWeb3アプリと言えど、フロント側(Web)はNext.jsなどのWeb2の技術を使用する必要があります。幸いなことにWeb3のバックエンド側はThirdwebなどのサードパーティのサービスを利用することで、サーバ側(スマートコントラクト)の知識がなくてもカスタムフックを利用することで簡単にWeb3アプリが作れてしまいます。

Web2でもFirebaseやAWS Lambdaなどのサーバレス・サービスでサーバ側の知識がなくてもシステムを開発できますがそれに近い感覚です。

Web3のバックエンドとは何なのか?

乱暴なメタファーで言えば、ブロックチェーンとはデータベースであり、スマートコントラクトとはストアードプロシージャになります。更新系のトランザクションは有料であり、呼び出すごとにウォレットから手数料が徴収されます。

ThirdwebのReactJSのカスタムフックを利用すると、クライアントアプリからストアード(スマコン)を叩くことができます。レスポンスがJSONで返ります。

データベースにはNFTテーブルを作成します。1つのレコードが1つのNFTトークンに対応します。そして各レコードには所有者IDカラムや画像URLカラムがあります。レコードの数に制限を加えることで希少価値が生まれます。

仮にSQLが書けるとすると、自分自身が所有するNFTを取得するSQLは以下のようになります。

擬似的なSQL
SELECT owner_id, nft_token_id, img_url FROM nft_table WHERE owner_id = :my_wallet_id

実際にやってみる

こちらのThirdweb(Solana)の公式ブログに基づいて説明していきます。

https://blog.thirdweb.com/guides/create-an-nft-gated-site-on-solana/

またこちらのチュートリアルビデオも参考にしてください。

全体の手順

  1. SolanaのウォレットPhantomをインストール
  2. ThirdwebのサイトでNFTの作成
  3. Next.jsアプリの作成

1. Phantomウォレット

ウォレットとは、Gmailアカウントにお財布機能が付いたようなものです。Web2ではログイン機能はGmailやFacebookのOAuth、決済機能はクレカと別々になっています。Web3ではそれが合体しています。メールアドレス間でお金を送金できるようなものです。

SolanaではPhantomというChrome拡張のウォレットを使用します。イーサのMetamaskのようなものになります。インストール方法はこちらの動画をご覧ください。ウォレットはまだ空っぽですから入金しましょう。開発用のトークンがこちらのページから無料でもらえます。

2. Thirdwebのサイト

ThirdwebのサイトからNFTを作成します。先ほど作成したPhantomウォレットでThirdwebのページにログインします。手順はこちらのビデオを参考にしてください。
設定するときに、開発用のDevnetを選択すること。アップロードした画像は誰でも見ることができること。販売価格を0.01と少額にすること。に注意してください。
この時点では全てのNFTはまだ誰にも販売されていない状態(unclaimed)になります。

こんな感じにNFTを作成します。この画像ではNFTが全て販売済みの状態になっています。実際にはTOKEN IDunclaimedになります。

3. Next.jsのプロジェクトを作成

Next.jsプロジェクトを新規作成し、以下の機能を実装していきます。

  1. ウォレットでのログイン、ログアウト
  2. NFTの購入
  3. NFTの一覧表示
  4. NFTの譲渡

何の変哲もない、通常のNext.jsプロジェクトを作成します。プロジェクト名をsolana-thirdweb-nft-gated-appとします。

Next.jsプロジェクトの作成
npx create-next-app -e with-tailwindcss solana-thirdweb-nft-gated-app

thirdwebsolanaのライブラリをインストールします。

ライブラリのインストール
yarn add @thirdweb-dev/react @thirdweb-dev/sdk @solana/wallet-adapter-react @solana/wallet-adapter-react-ui @solana/wallet-adapter-wallets

.env.localファイルにプライベートキープログラムIDを設定します。プログラムIDは外部に公開しても安全ですので、NEXT_PUBLIC_に設定しても大丈夫です。

RIVATE_KEY=3obL1P...
NEXT_PUBLIC_PROGRAM_ADDRESS=C7Q2ay7g...

_app.tsxにウォレットのProviderを設置します。next-authのようにapiを使用して認証します。

_app.tsx
import '../styles/globals.css'
import type {AppProps} from 'next/app'

import {ThirdwebProvider} from "@thirdweb-dev/react/solana"
import {Network} from "@thirdweb-dev/sdk/solana"
import {WalletProvider} from "@solana/wallet-adapter-react"
import {PhantomWalletAdapter} from "@solana/wallet-adapter-wallets";

export const network: Network = 'devnet' // 開発用のネットワーク
export const domain = "example.org"
export const wallet = new PhantomWalletAdapter()

function MyApp({Component, pageProps}: AppProps) {
    return (
        <ThirdwebProvider
            authConfig={{
                authUrl: "/api/auth",
                domain: process.env.VERCEL_URL || domain
            }}
            network={network}
        >
            <WalletProvider wallets={[wallet]}>
                <Component {...pageProps} />
            </WalletProvider>
        </ThirdwebProvider>
    )
}

export default MyApp

authのライブラリをインストールします。

 yarn add @thirdweb-dev/auth
auth.config.ts
import {ThirdwebAuth} from "@thirdweb-dev/auth/next/solana"
import {domain} from "./pages/_app"

export const {ThirdwebAuthHandler, getUser} = ThirdwebAuth({
    privateKey: process.env.PRIVATE_KEY!,
    domain,
})
api/auth/[...thirdweb].ts
import {ThirdwebAuthHandler} from "../../../auth.config"

export default ThirdwebAuthHandler();

ログインページを作成する

ウォレットにログイン(接続)するためのページ(login.tsx)を作成します。カスタムフックで提供されていますのでボタンのイベントハンドラで呼び出します。ウォレットでログインするとuseUser()にユーザ情報がセットされます。

login.tsx
import {
    useLogin,
    useLogout,
    useUser,
} from "@thirdweb-dev/react/solana";

function LoginPage() {
    const login = useLogin()
    const logout = useLogout()
    const {user} = useUser() // ログインするとセットされます。

    const handleLogin = async () => {
        await login() // Phantomウォレットが起動します。
    }

    return (
        <div>
            <main>
                <h1>Thirdweb Solanaのページ</h1>
                {user ? (
                        <div>
                            ログインユーザ: {user.address}
                            <button className="border-2" onClick={logout}>ログアウト</button>
                        </div>
                    ) : (
                        <div>
                            <button className="border-2" onClick={handleLogin}>ログイン</button>
                        </div>
                    )}
            </main>
        </div>
    )
}

パブリックにデプロイしなくても、Next.jsをyarn devでローカルホストで起動、実行することができます。

NFT購入ボタンを実装する

NFTを購入するボタンを設置します。

login.tsx
	<div>
	    <p>ログインユーザ: {user.address.slice(0, 10)}***</p>
	    <button className="border-2" onClick={handlePurchase}>NFTを購入する</button>
	    <button className="border-2" onClick={logout}>ログアウト</button>
	</div>

NFTを購入するカスタムフックuseClaimNFTを設置します。mutateAsyncの名の通り、更新処理になります。NFTの所有者をウォレットのユーザに変更します。

login.tsx
    const {program} = useProgram(
        process.env.NEXT_PUBLIC_PROGRAM_ADDRESS,
        'nft-drop'
    )
    const {mutateAsync: claim} = useClaimNFT(program)

イベントハンドラーでカスタムフックを呼び出します。

login.tsx
   const handlePurchase = async () => {
        await claim({
            amount: 1 // NFTを1つ購入
        })
    }

購入ボタンを押すとウォレットがポップオーバーします。Approveをクリックすると、決済トランザクションが走ります。NFTの料金と手数料がウオレットから引かれます。

Phantomウォレット

Thirdwebダッシュボードから処理が正常終了したか確認します。成功すると一番右側のカラムに購入者のウォレットのIDがセットされます。

Thirdwebダッシュボード

作成したNFTはSolanaのパブリックチェーンにありますので、Solanaのコンソール(SOLSCAN)から確認することができます。つまり、作成したNFTの所有者は自分自身であり、Thirdwebのものではありません。もちろん、Thirdwebのスマートコントラクトやライブラリに不正やセキュリティホールが全くないとは言い切れません。

購入したNFTを一覧表示する

何の変哲もない、リスト型をmapで処理するコードです。usersNftにNFT一覧を保持します。

login.tsx
	<ul>
	    {usersNft.map(nft => {
		const {id, name, image} = nft.metadata
		return <li key={id}>{id.slice(0, 5)} : {name} : {image}</li>
	    })}
	</ul>
    </main>

usersNftにNFT一覧をセットするコードを書きます。カスタムフックuseNFTsを呼び出すと、NFT一覧が取得できます。

login.tsx
function LoginPage() {
    const [usersNft, setUsersNft] = useState<NFT[]>([]);
    const {data: nfts} = useNFTs(program)

    useEffect(() => {
        const myNfts = nfts?.filter((nft) => nft.owner === user?.address) ?? [] // ウォレットのユーザが所有するNFTのみにフィルタリング
        setUsersNft(myNfts)
    }, [nfts, user])

ウォレットで接続し、NFTの購入が正常終了すると購入したNFT一覧が表示されます。画像ファイル自体はSolanaブロックチェーンとは別のシステムに保存されます。IPFSという分散ファイルシステムに格納されます。

ウォレットから購入したNFTを確認することもできます。

NFTでページのアクセスコントロール

NFTを購入した人だけ閲覧できるページを作ります。SSRでgetServerSidePropsにアクセスコントロールを実装します。
フックは使用できませんので、ダイレクトにSDKの関数getAllClaimedを呼び出します。
NFTを所有してない場合は、/loginにリダイレクトさせます。所有していればそのまま会員ページをレンダリングします。

paywall.ts
import type {GetServerSideProps, NextPage} from 'next'
import {ThirdwebSDK} from "@thirdweb-dev/sdk/solana";
import {getUser} from "../auth.config"
import {network} from "./_app"

// SSRでのアクセスコントロール実装
export const getServerSideProps: GetServerSideProps = async ({req, res}) => {
    const sdk = ThirdwebSDK.fromNetwork(network)
    const user = await getUser(req)

    if (!user) return { // ウォレットに接続してるか?
        redirect: {
            destination: "/login",
            permanent: false
        }
    }

    const program = await sdk.getNFTDrop(
        process.env.NEXT_PUBLIC_PROGRAM_ADDRESS!
    )
    const nfts = await program.getAllClaimed()
    const hasNft = nfts.find((nft: any) => nft.owner === user.address)
    if (!hasNft) return { // NFTを所有してるか?
        redirect: {
            destination: "/login",
            permanent: false
        }
    }
    return {props: {}} // クラサバ間でJSONデータが飛ぶので機密情報を渡さないように。
}
// NFTを所有しているとレンダリングされる。
const Home: NextPage = () => {
    return (
        <div>会員専用ページだよ!</div>
    )
}

このようなNFTの使い方をユーティリティNFTと言います。メンバーシップ(会員制)のサービスに利用します。Zennの本もNFTとして購入できるようになると良いですね。

NFTを譲渡する

NFTを他人のウォレットに譲渡するにはカスタムフックuseTransferNFT(送信先アドレス、NFTトークンID)を使います。以下はNFTを一覧表示と、送信先テキストボックス、送信ボタンをつけたReactコンポーネントです。

NFTs.tsx
const NFTs = (): JSX.Element => {
    const {user} = useUser()
    const inputEl = useRef<HTMLInputElement>(null);
    const {program} = useProgram(
        process.env.NEXT_PUBLIC_PROGRAM_ADDRESS,
        'nft-drop'
    )

    const [usersNft, setUsersNft] = useState<NFT[]>([]);
    const {data: nfts, isLoading} = useNFTs(program)
    const {mutateAsync: transfer} = useTransferNFT(program);

    useEffect(() => {
        const myNfts = nfts?.filter((nft) => nft.owner === user?.address) ?? []
        setUsersNft(myNfts)
    }, [nfts, user])

    const handleTransfer = async (id: string) => {
        if (inputEl.current) {
            await transfer({ // NFTを譲渡します
                receiverAddress: inputEl.current.value, // 譲渡先ウォレット
                tokenAddress: id, // 譲渡するNFTのID
            })
        }
    }

    if (isLoading) {
        return <div>loading...</div>
    }

    return (
        <div>
            <input ref={inputEl} type="text"/>
            <ul>
                {usersNft.map(nft => {
                    const {id, name, image} = nft.metadata
                    return (
                        <li key={id}>{id.slice(0, 5)} : {name} : {image}
                            <button className="border-2" onClick={() => handleTransfer(id)}>NFTを譲渡</button>
                        </li>
                    )
                })}
            </ul>
        </div>
    )
}

SOLSCANからトランザクション履歴を見ます。正常にNFTトークンが別のウォレットに移動したことを確認します。

以上で説明を終わります。

次回は

バックエンド部分のRustスマートコントラクトやイーサなどの他のThirdwebサービスを説明したいと思います。

Discussion