📨

【Solidity】招待制の会員権NFTを作ってみる

2022/01/21に公開

前置き

この記事は特定のNFTや仮想通貨の購買を促進する記事ではありません。

招待制の会員権NFTとは?

招待制の前に会員権から。
NFTはその性質からデジタル会員権としての役割を持つこともできる。実際のケースとして、FLYFISH CLUB がある。

https://realsound.jp/tech/2022/01/post-949160.html

世界初の“NFTレストラン”となる「Flyfish Club」を来年2023年にオープンすると発表
米ニューヨークに物理的店舗として登場
NFTを所持している人のみが会員資格を得ることができる
会員用のNFTは2.5ETHのものと、4.25ETHの2種類あり、それぞれ現在の価格でおよそ95万円と140万円
「メンバーシップはNFT所有者の資産となり、購入後に流通市場で他の人に販売、譲渡、またはリースすることができる」としており、これ以外に年会費や手数料などは発生しないとしている。

リアル店舗ではキャパを超えないように大元で発行数を制限・調整する必要があるが、そうでない場合もありそう。という仮説のもと、 会員権NFTを持っている人だけが会員権NFTをmint/transferできる という仕組みを作ってみる。

Clubhouse初期段階で、Clubhouseやっている人だけがClubhouseに招待できるような仕組みをイメージしてもらうとわかりやすそう。

実装

無制限に招待できるパターンと1人あたり2人までしか招待できないパターンを考えてみる

環境構築

# hardhatのインストール
npm install --save-dev hardhat`
npx hardhat

# openzeppelinのインストール
npm install @openzeppelin/contracts

無限招待

// contracts/TakaiSushiMembership.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

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

contract TakaiSushiMembership is ERC721, Ownable {
    using Counters for Counters.Counter;
    Counters.Counter private _tokenCounter;

    constructor() ERC721("TakaiSushi", "SUSHI") {}

    // owner() -> コントラクトをデプロイした本人か?もしくは
    // balanceOf(msg.sender) >=1 -> NFTを持っている=会員か?
    modifier onlyOwnerOrMember() {
        require(
            msg.sender == owner() || balanceOf(msg.sender) >= 1,
            "not member"
        );
        _;
    }

    // 招待相手のアドレスを引数とすることでmintとtransferを同時に行う
    function mintAndTransfer(address _to) public onlyOwnerOrMember {
        _tokenCounter.increment();

        uint256 _newItemId = _tokenCounter.current();
        _safeMint(msg.sender, _newItemId);
        safeTransferFrom(msg.sender, _to, _newItemId);
    }

    function getTotalSupply() external view returns (uint256) {
        return _tokenCounter.current();
    }
}

最小のコードだとたったこれだけでできる。技術的にはそこまで難しくない。
modifier onlyOwnerOrMember が1番のポイント。

テストも書いておこう

// test/TkaiSushiMembershipTest.js
const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("TakaiSushiMembership", function () {
  let takaiSushiMembershipFactory; // デプロイ用のデータ
  let takaiSushiMembership; // デプロイした実際のコントラクトアドレス
  let owner;
  let addr1;
  let addr2;
  let addrs;

  beforeEach(async function () {
    takaiSushiMembershipFactory = await ethers.getContractFactory(
      "TakaiSushiMembership"
    );
    [owner, addr1, addr2, ...addrs] = await ethers.getSigners();

    takaiSushiMembership = await takaiSushiMembershipFactory.deploy();
  });

  describe("mint", function () {
    // コントラクトをデプロイした本人は条件なしに招待できる
    it("mint owner success", async function () {
      const mintTx = await takaiSushiMembership.mintAndTransfer(addr1.address);
      await mintTx.wait();
      expect(await takaiSushiMembership.ownerOf(1)).to.be.equal(addr1.address);
      expect(await takaiSushiMembership.getTotalSupply()).to.be.equal(1);
    });
    // NFTをもっていない人はmintできないことを確認
    it("mint not member failure", async function () {
      await expect(
        takaiSushiMembership.connect(addr1).mintAndTransfer(addr2.address)
      ).to.be.revertedWith("not member");
      await expect(
        takaiSushiMembership.connect(addr2).mintAndTransfer(owner.address)
      ).to.be.revertedWith("not member");
    });
    // NFTを持っている人(会員)はmintできることを確認
    it("mint member success", async function () {
      const mintTx = await takaiSushiMembership.mintAndTransfer(addr1.address);
      await mintTx.wait();
      expect(await takaiSushiMembership.ownerOf(1)).to.be.equal(addr1.address);
      const mintTx1 = await takaiSushiMembership
        .connect(addr1)
        .mintAndTransfer(addr2.address);
      await mintTx1.wait();
      expect(await takaiSushiMembership.ownerOf(2)).to.be.equal(addr2.address);
    });
  });

  describe("total supply", function () {
    it("getTotalSupply", async function () {
      expect(await takaiSushiMembership.getTotalSupply()).to.be.equal(0);
      const mintTx = await takaiSushiMembership.mintAndTransfer(addr1.address);
      await mintTx.wait();
      expect(await takaiSushiMembership.getTotalSupply()).to.be.equal(1);
      const mintTx2 = await takaiSushiMembership.mintAndTransfer(addr2.address);
      await mintTx2.wait();
      expect(await takaiSushiMembership.getTotalSupply()).to.be.equal(2);
    });
  });
});

テストの実行コマンドは以下

npx hardhat test

サンプルは以下↓

https://rinkeby.etherscan.io/address/0x19DF1cF24dD233f1458e216441477Bf7d5AF0537

招待制限あり(2人まで)

// contracts/TakaiSushiLimitedMembership.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
import "@openzeppelin/contracts/utils/math/SafeMath.sol";
import "hardhat/console.sol";

contract TakaiSushiLimitedMembership is ERC721, Ownable {
    using SafeMath for uint256;
    using Counters for Counters.Counter;
    Counters.Counter private _tokenCounter;

    // 1人あたり何回招待できるか?
    uint256 constant MAX_INVITE = 2;

    // 制限はaddressに紐づく
    // 招待するたびに addressをkeyにvalueが0,1,2...と増えていく
    mapping(address => uint256) memberInviteCount;

    constructor() ERC721("TakaiSushi", "SUSHI") {}

    modifier onlyOwnerOrMember() {
        require(
            msg.sender == owner() || balanceOf(msg.sender) >= 1,
            "not member"
        );
        _;
    }

    // msg.sender == owner() -> コントラクトをデプロイした人は無制限に招待できる
    // memberInviteCount[msg.sender] < 2 -> 招待回数が2回未満の場合は招待できる
    modifier canInvite() {
        require(
            msg.sender == owner() || memberInviteCount[msg.sender] < 2,
            "over invite limit"
        );
        _;
    }

    function mintAndTransfer(address _to) public onlyOwnerOrMember canInvite {
        _tokenCounter.increment();

        uint256 _newItemId = _tokenCounter.current();
        _safeMint(msg.sender, _newItemId);
        safeTransferFrom(msg.sender, _to, _newItemId);
	
	// 招待カウント処理
        memberInviteCount[msg.sender] = memberInviteCount[msg.sender].add(1);
    }

    function getTotalSupply() external view returns (uint256) {
        return _tokenCounter.current();
    }
}
// contracts/TakaiSushiLimitedMembershipTest.js
const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("TakaiSushiLimitedMembership", function () {
  let takaiSushiMembershipFactory; // デプロイ用のデータ
  let takaiSushiMembership; // デプロイした実際のコントラクトアドレス
  let owner;
  let addr1;
  let addr2;
  let addr3;
  let addr4;
  let addrs;

  beforeEach(async function () {
    takaiSushiMembershipFactory = await ethers.getContractFactory(
      "TakaiSushiLimitedMembership"
    );
    [owner, addr1, addr2, addr3, addr4, ...addrs] = await ethers.getSigners();

    takaiSushiMembership = await takaiSushiMembershipFactory.deploy();
  });

  describe("mint", function () {
    // 途中略...
    // コントラクトをデプロイした人は無制限に招待できることを確認
    it("owner is infinity invite", async function () {
      const mintTx = await takaiSushiMembership.mintAndTransfer(addr1.address);
      await mintTx.wait();
      const mintTx1 = await takaiSushiMembership.mintAndTransfer(addr2.address);
      await mintTx1.wait();
      const mintTx2 = await takaiSushiMembership.mintAndTransfer(addr3.address);
      await mintTx2.wait();
      const mintTx3 = await takaiSushiMembership.mintAndTransfer(addr4.address);
      await mintTx3.wait();
      expect(await takaiSushiMembership.getTotalSupply()).to.be.equal(4);
    });
    // 会員は最大2回まで招待できる
    it("member invite limit", async function () {
      const mintTx = await takaiSushiMembership.mintAndTransfer(addr1.address);
      await mintTx.wait();
      const mintTx1 = await takaiSushiMembership
        .connect(addr1)
        .mintAndTransfer(addr2.address);
      await mintTx1.wait();
      const mintTx2 = await takaiSushiMembership
        .connect(addr1)
        .mintAndTransfer(addr3.address);
      await mintTx2.wait();
      await expect(
        takaiSushiMembership.connect(addr1).mintAndTransfer(addr4.address)
      ).to.be.revertedWith("over invite limit");
      expect(await takaiSushiMembership.getTotalSupply()).to.be.equal(3);
    });
  });
  
  // ...
});

サンプルは以下↓

https://rinkeby.etherscan.io/address/0x2949c658ED864a79B6DdbbDA3BeEB6Bb4a80ACDA

まとめ

  • コントラクト側で「NFTを持っている人のみNFTを発行できる」ような仕組みを作った
  • 実際に書いてみると技術的にそこまで難しいものではなかったが、こういうことを実現したいが困っている人のために情報を記しておく。

etc

Solidityについてワイワイ学ぶコミュニティ「solidity-jp」を作りました!
いまから学んでみたい/学習中だけどの日本語の情報が少ない/古くて時間がかかっているという方、一緒に学びましょう〜!!

https://solidity-jp.dev/

また、TwitterにてSolidityについて技術的な部分を発信しています。良ければフォローお願いします!

https://twitter.com/k0uhashi

Discussion