⛓️

NFT所有によるDiscordチャンネル制限

2022/02/10に公開

NFT所有によるDiscordチャンネル制限とは

特定のNFTを所有していたら、Discordのチャンネルに参加できるという機能です。

具体的には、NFTを所有していたら特定のロールが付与され、特定のロールを持つユーザーしか入れないようなチャンネルに参加できるようになる機能です。Discord周りでは、DAOでの活動を外部でも証明できるようなサービスもあるみたいなことを上司から聞きました。

この機能自体が実際に使われるかどうかはさておき、応用できる部分もあると思いますので、実装方法をまとめました。

既にbotが存在するようなので、勉強のためというよりも、単純にDiscordの運用で導入したいということであれば、検索したらヒットすると思いますのでそちらをご使用ください。

前提条件

今回は環境構築やデプロイのプロセスは説明しませんので、以下の知識が前提条件となります。

  • HardhatまたはTruffleを用いた基本的なコントラクト開発ができる
  • JavaScript, Reactが理解できる

開発環境

  • Next.js: v12.0.10
  • Node.js: v14.x
  • Solidity: v0.8.4
  • discord.js: v12.3.0

Discordのセットアップ

Discord上ではボットを作成していきます。

Discord Developer Portalにアクセスし、右上の「New Application」からアプリケーションを作成します。

次はBotを作成します。サイドバーの「Bot」をクリックし、右上の「Add Bot」からボットを作成します。

「OAuth2」の「General」にいき、「Redirects」を設定します。最初はlocalhostでデバッグすることをお勧めします。

次に「OAuth2」の「URL Generator」でURLを生成します。

このURLはNFT所有を検証し、チャンネルに参加したい場合にアクセスするURLとなります。

以下のようなURLになると思うので、response_type=codeからresponse_type=tokenに変更しましょう。

https://discord.com/api/oauth2/authorize?client_id=939736065105858630&redirect_uri=http%3A%2F%2Flocalhost%3A3000&response_type=code&scope=identify

試しにアクセスしてみると、以下のようなURLにリダイレクトされます。

http://localhost:3000/#token_type=Bearer&access_token=xxxx&expires_in=604800&scope=identify

次は、ボットをチャンネルに招待するためのURLを生成します。「bot」と「Manage Roles」にチェックを入れて、生成されたURLにアクセスしてボットを招待しましょう。

「開発者モード」をオンにしておきましょう

次にロールを作成します。「サーバー設定」→「ロール」からロールが作成できます。

ロールを作成したら、右クリックでIDをコピーできますので覚えておいてください。

また、ロールの並び順によって付与できるロールが異なってきます。特定のロールを持っているユーザーはそのロールより下に位置するロールのみ付与することができるようになっています。以下の画像で言えば、AdminCrypto Expertなどをロールを誰かに付与することができます。

実装

これより先の説明で用いるコードでは一部しか載せていないため、詳しくはこちらをご参照ください。

Solidity

NFTの実装がメインではないため、コードの説明やデプロイ方法などは割愛させていただきます。
ちなみに、Solidityを書かなくてもNFTやマーケットプレースが開発できちゃうサービスが出てきていて、それを使えばボタンポチポチするだけでコントラクト作れるのでおすすめです。

https://www.bunzz.dev/

CryptoCocoA.sol
pragma solidity >=0.8.0 <0.9.0;
//SPDX-License-Identifier: MIT

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

contract CryptoCocoA is ERC721URIStorage, Ownable {
    using Counters for Counters.Counter;
    Counters.Counter private tokenId;

    string private baseURI;

    constructor() ERC721("CryptoCocoA", "COCOA") {
        baseURI = "ipfs://";
    }

    function mint(string memory cid) public onlyOwner {
        string memory image = string(abi.encodePacked(baseURI, cid));
        string memory json = Base64.encode(
            bytes(
                string(
                    abi.encodePacked(
                        '{ "name": "CryptoCocoA #',
                        Strings.toString(tokenId.current()),
                        '", "description": "A NFT-powered Crypto CocoA", ',
                        '"traits": [], ',
                        '"image": "',
                        image,
                        '"}'
                    )
                )
            )
        );

        string memory tokenURI = string(
            abi.encodePacked("data:application/json;base64,", json)
        );

        _safeMint(msg.sender, tokenId.current());
        _setTokenURI(tokenId.current(), tokenURI);

        tokenId.increment();
    }
}
Base64.sol
pragma solidity >=0.8.0 <0.9.0;

// SPDX-License-Identifier: MIT
/// [MIT License]
/// @title Base64
/// @notice Provides a function for encoding some bytes in base64
/// @author Brecht Devos <brecht@loopring.org>
library Base64 {
    bytes internal constant TABLE =
        "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";

    /// @notice Encodes some bytes to the base64 representation
    function encode(bytes memory data) internal pure returns (string memory) {
        uint256 len = data.length;
        if (len == 0) return "";

        // multiply by 4/3 rounded up
        uint256 encodedLen = 4 * ((len + 2) / 3);

        // Add some extra buffer at the end
        bytes memory result = new bytes(encodedLen + 32);

        bytes memory table = TABLE;

        assembly {
            let tablePtr := add(table, 1)
            let resultPtr := add(result, 32)

            for {
                let i := 0
            } lt(i, len) {

            } {
                i := add(i, 3)
                let input := and(mload(add(data, i)), 0xffffff)

                let out := mload(add(tablePtr, and(shr(18, input), 0x3F)))
                out := shl(8, out)
                out := add(
                    out,
                    and(mload(add(tablePtr, and(shr(12, input), 0x3F))), 0xFF)
                )
                out := shl(8, out)
                out := add(
                    out,
                    and(mload(add(tablePtr, and(shr(6, input), 0x3F))), 0xFF)
                )
                out := shl(8, out)
                out := add(
                    out,
                    and(mload(add(tablePtr, and(input, 0x3F))), 0xFF)
                )
                out := shl(224, out)

                mstore(resultPtr, out)

                resultPtr := add(resultPtr, 4)
            }

            switch mod(len, 3)
            case 1 {
                mstore(sub(resultPtr, 2), shl(240, 0x3d3d))
            }
            case 2 {
                mstore(sub(resultPtr, 1), shl(248, 0x3d))
            }

            mstore(result, encodedLen)
        }

        return string(result);
    }
}

フロントエンド

処理手順

  1. Discordのユーザー情報を取得して表示する
  2. Metamaskと接続する
  3. ユーザーがNFTを所有しているか確認する
  4. ユーザーに適当なテキストに署名してもらう
  5. その署名とDirscordのユーザーIDをロール付与用エンドポイントに送信する

実装

  1. URLのパラメータに存在するトークンなどを用いて、ユーザー情報を取得します。
fetchUsers
const fetchUsers = async () => {
    const fragment = new URLSearchParams(window.location.hash.slice(1));
    const [accessToken, tokenType] = [
      fragment.get("access_token"),
      fragment.get("token_type"),
    ];

    if (!accessToken) return;

    const result = await fetch("https://discord.com/api/users/@me", {
      headers: {
        authorization: `${tokenType} ${accessToken}`,
      },
    });
    const res = await result.json();
    setUser({
      ...res,
      avatar: `https://cdn.discordapp.com/avatars/${res.id}/${res.avatar}.png`,
    });
  };
  1. Metamaskと接続する
requestToConnect
  const requestToConnect = async () => {
    if (!provider) throw new Error("Provider is not initialized");

    const accounts = await provider.send("eth_requestAccounts", []);
    if (accounts.length) {
      setAccountAddress(accounts[0]);
      setIsConnected(true);
    } else {
      setIsConnected(false);
    }
  };
  1. ユーザーがNFTを所有しているか確認する
  2. ユーザーに適当なテキストに署名してもらう
  3. その署名とDirscordのユーザーIDをロール付与用エンドポイントに送信する
verify
const verify = async () => {
    if (
      !wallet.accountAddress ||
      !wallet.nftContract ||
      !wallet.signer ||
      !user
    )
      return;

    try {
      setIsVerifing(true);
      const res = await wallet.nftContract.balanceOf(wallet.accountAddress);

      // 3. ユーザーがNFTを所有しているか確認する
      if (res.toNumber() === 0) {
        toast({
          status: "error",
          title: "CryproCocoAを所有していません。購入する必要があります。",
        });
        setIsVerifing(false);
        setIsVerified(false);
        return;
      }

      // 4. ユーザーに適当なテキストに署名してもらう
      const message = await wallet.signer.signMessage("Have a good dapp dev!");
      // 5. その署名とDirscordのユーザーIDをロール付与用エンドポイントに送信する
      const result = await fetch(`/api/verify?&mes=${message}&user=${user.id}`);
      if (result.status === 200) {
        setIsVerified(true);
        setIsVerifing(false);
        toast({
          status: "success",
          title: "Success!",
        });
      } else {
        const data = await result.json();
        throw new Error(data.message);
      }
    } catch (error) {
      console.log(error);
      setIsVerifing(false);
      if (error instanceof Error) {
        toast({
          status: "error",
          title: "Error",
          description: error.message,
        });
      }
    }
  };

バックエンド

処理手順

  1. 署名を復元し、アドレスを取得する
  2. 復元されたアドレスがNFTを所有しているか再確認
  3. NFTを持っていたらロール付与

実装

実装はNext.jsのAPI Routesで行っていますが、なんでも大丈夫です。少し書き方は異なりますが適宜調べて実装してみてください。

書き方適当な部分がありますので、実運用で使用する場合はしっかりとした記述方法で書き直すようご注意ください。

今回Infuraを使用しています。projectIdなどはご自身のものをご使用ください。

また、discordTokenは以下の部分から取得できます。

const client = new Client();
const provider = new ethers.providers.InfuraWebSocketProvider(
  network,
  projectId
) as Provider;

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const clientMessage = req.query.mes as string;
  const userId = req.query.user as string;
  await client.login(discordToken);

  // 1. 署名を復元し、アドレスを取得する
  const address = ethers.utils.verifyMessage(message, clientMessage);
  if (!address) {
    res.status(400).json({ message: "Signed message is incorrect." });
    return;
  }

  // 2. 復元されたアドレスがNFTを所有しているか再確認
  const erc721 = await ERC721;
  const contract = new ethers.Contract(contractAddress, erc721.abi, provider);
  const result = await contract.balanceOf(address);
  if (result.toNumber() === 0) {
    res.status(400).json({ message: "You do not own any CryptoCocoA." });
    return;
  }

  try {
    // 3. 持っていたらロール付与
    const guild = await client.guilds.fetch(guildId);
    const user = await guild?.members.fetch(userId);
    await user.roles.add(cocoaRole);
    res.status(200).json({ message: "Success!" });
  } catch (error) {
    console.log(error);
    if (error instanceof Error) {
      res.status(400).json({ message: error.message });
      return;
    }

    res.status(400).json({ message: "Something went wrong." });
  }
}

以上がざっくりとした実装となります。

追加開発

これだけでは実運用で使用する場合不十分です。入室時はNFTを持っていてもその後売った場合にその人をチャンネルから追い出すなどの処理が必要だったりします。

botに毎朝ユーザーがNFTを持っているかどうかチェックしてもらい、所有していなければキックという処理になると思います。もしご興味がある方は実装してみてください。

抜けている部分や間違った部分などあれば、お気軽にコメントください🙏

コミュニティ

Web3, ブロックチェーン, Solidity学習用のコミュニティを作成しましたので、是非お気軽にご参加ください。もちろん初心者も大歓迎です。僕自身も初心者なので、一緒に勉強しながら成長できたらと思います。

コンセプトは、「恐れるな!」です。新領域はまだまだ未開拓で、皆知らないことばかりです。知らないことは恥ずかしいことではない、なんでも質問して、誰でも答えて、間違いがあれば気づいた人が教えてあげ、一緒にこの領域を楽しみましょう:)

https://discord.gg/sAmeXSKgnJ

Twitter: @show_clements

Discussion