🎫

kamon NFT のソースコード解説

2022/10/17に公開

2022/10/18 Henkakuコミュニティ内、Kamon NFTの開発に関わっているものとしてソースコード解説をしますので、その説明資料になります。

ソースコードの全体は下記にあります。
https://github.com/henkaku-center/kamon-nft

なお、既に、聴いてトークンを稼ぐ「Listen To Earn」の機能に関しては解説記事が出てましたのでご参考にしてください。
https://zenn.dev/pitpa/articles/about-membership-nft

開発環境

Solidtyの開発環境

Solidtyの開発環境として Hardhat を採用しています。truffle や Foundry といったツールもありますが、Hardhat は手軽に作れることがメリットかなと思います。

https://hardhat.org/

  describe('mint', () => {
    it('cannot mint twice for the same user(owner)', async function () {
      const mintTx = await contract.mint(
        'https://example.com/podcast.png',
        ['Podcast Contributor'],
        alice.address
      )
      await mintTx.wait()
      await expect(
        contract.mint(
          'https://example.com/podcast.png',
          ['Podcast Contributor'],
          alice.address
        )
      ).eventually.to.rejectedWith(Error)
    })
  })

(同じアドレスに2つは持てないことを確認している)

詳細なテストコードの解説はしませんが、上記のように chai を使って手軽に node でテストコードなどが実行できルコとあたりが手軽さにつながっていると思います。

etherscan, polygonscan でコードをverify(公開する)する連携が簡単だったり、hardhat-contract-sizer 等でコントラクトサイズの確認も簡単にできるも便利です。

Henkakuコミュニティでは web3 の実験場として、どこまでが技術的にできるかを知ることも目的がったりするので、コントラクトサイズの制約(現状24576 bytes)に引っかかることも多く便利です。

コントラクトサイズを小さくにするには https://ethereum.org/ja/developers/tutorials/downsizing-contracts-to-fight-the-contract-size-limit/ を参考にしても良いでしょう

Github Actions を使ってプルリクエストごとにコントラクトサイズを自動でコメントするようにしていてこれも便利です。

https://github.com/henkaku-center/kamon-nft/blob/ec44edeb315968ff68b573f9147f145e8f656a96/.github/workflows/pull_request.yaml#L44

フォーマッタ

フォーマッタは、prettier、prettier-plugin-solidity、solhint、solhint-plugin-prettier あたりを設定しています。あまり、こだわりはないので細かい設定はしていないのですが、チーム開発でレビューの際に変に差分が出ないので便利です。

デプロイ

デプロイについては alchemy をで行っています。
https://www.alchemy.com/

testデプロイスクリプトは下記にあります。
https://github.com/henkaku-center/kamon-nft/blob/main/scripts/deployTest.js

kamon NFT はコミュニティ内のみで流通しているHenkakuトークンを使ってmintする形になっていますので、テスト時にはこのトークン(MockERC20)も併せてデプロイしてます。

ソースコード解説

ここから、メインのソースコードの解説です。

コード全文はこちらです。
https://github.com/henkaku-center/kamon-nft/blob/main/contracts/kamonNFT.sol

上から見ていきます。

import

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

import {IERC20} from "./interface/IERC20.sol";

openzeppelinのコードを使わせてもらっています。ERC721 などをつく際には最高に便利です。
IERC20 については https://github.com/henkaku-center/kamon-nft/blob/main/contracts/interface/IERC20.sol で定義していて、Henkakuトークンをやりとりするために定義しています。

State Variables

状態変数でのポイントは下記のあたりかと思います。

    struct Attributes {
        uint256 point;
        uint256 claimableToken;
        uint256 answeredAt;
    }
    
    
    mapping(address => Attributes) public userAttribute;

アドレスごとに、ポイント、獲得できるトークン数、いつ回答したかのデータを保持するようにしています。

override

既存の定義を上書きしているところでピックアップするのであれば_afterTokenTransferかなと思います。NFTを転送したときに、ロールや上記のアドレスごとに紐づくデータも移動するように定義しています。

    function _afterTokenTransfer(
        address _from,
        address _to,
        uint256 _tokenId
    ) internal virtual override {
        roles[_to] = roles[_from];
        tokenIdOf[_to] = tokenIdOf[_from];
        userAttribute[_to] = userAttribute[_from];
        delete roles[_from];
        delete tokenIdOf[_from];
        delete userAttribute[_from];
    }

modifier

modifier はホルダーとそうでない人でできることを分けたいので、それぞで用意しています。なおメッセージが短くなっているのはコントラクトサイズを寄り切り詰めたいからです。

    modifier onlyNoneHolder(address _address) {
        require(balanceOf(_address) == 0, "MUST BE NONE HOLDER");
        _;
    }

    modifier onlyHolder(address _address) {
        require(balanceOf(_address) != 0, "MUST BE HOLDER");
        _;
    }

internal function

mintするときの処理は内部関数として実装しています。難しいことはしてません。

    function _mint(
        string memory finalTokenUri,
        string[] memory _roles,
        address _to
    ) internal onlyNoneHolder(msg.sender) returns (uint256) {
        _tokenIds.increment();
        uint256 newItemId = _tokenIds.current();
        _safeMint(_to, newItemId);
        tokenIdOf[_to] = newItemId;
        roles[_to] = _roles;
        _setTokenURI(newItemId, finalTokenUri);
        return newItemId;
    }

admin function

管理者ができる関数をこちらで定義しています。なお管理者については multi-sig wallet などで複数人で運用できるようにしています。

    function setPrice(uint256 _price) public onlyOwner {
        require(_price >= 1e18, "MUST BE GTE 1e18");
        price = _price;
    }

    function setRewardPoint(uint256 _rewardPoint) public onlyOwner {
        rewardPoint = _rewardPoint;
    }

    function setRewardHenkaku(uint256 _rewardHenkaku) public onlyOwner {
        require(_rewardHenkaku >= 1e18, "MUST BE GTE 1e18");
        rewardHenkaku = _rewardHenkaku;
    }

こちらあたりで販売価格(Henkaku)や、ボーナスポイント(称号と連動するポイント)、リワードポイント(クイズに回答するともられるHenkaku)量を、別で変更できるようにしています。インフレ、またはデフレになったりするための準備です。

他にも細かい関数を実装していますが、それぞれ短いので興味がある方に見ていただければと思います。クイズのキーワードの登録などもできるようにしています。

public function

なるべく多くの関数を誰でも実行できるようにしています。

例えばhasRoleOfなどです。これはアドレスが特定のロールを持っているかを判定していて、ロールあるなしによってなにか特定の便益を受けられるUtilityとして機能することを期待しています。

    function hasRoleOf(address _address, string memory _role)
        public
        view
        returns (bool)
    {
        string[] memory _roles = roles[_address];
        for (uint256 i = 0; i < _roles.length; i++) {
            if (keccak256(bytes(_roles[i])) == keccak256(bytes(_role))) {
                return true;
            }
        }
        return false;
    }

他の大きな機能はHenkakuでmintできる機能です。下記のところで実装しています。Hekakuをコントラクトに送付してそれが成功した場合のみmintできる処理です。

    function mintWithHenkaku(string memory _tokenURI, uint256 _amount)
        public
        onlyNoneHolder(msg.sender)
    {
        require(bytes(_tokenURI).length > 0, "Invalid tokenURI");
        require(_amount >= price, "INSUFFICIENT AMOUNT");
        bool success = henkakuToken.transferFrom(
            msg.sender,
            address(this),
            _amount
        );
        require(success, "TX FAILED");
        string[] memory _roles = new string[](2);
        _roles[0] = "MEMBER";
        _roles[1] = "MINTER";
        uint256 newItemId = _mint(_tokenURI, _roles, msg.sender);
        emit BoughtMemberShipNFT(msg.sender, newItemId);
    }

なお、このHenkakuトークンが原資となりQuest(クイズ)のリワードが支払われます。今の所、破綻していません。

checkAnswer も大きいところですが、最初に記述した下記の記事で解説していただいているので割愛します。
https://zenn.dev/pitpa/articles/about-membership-nft

補足

  • ユーザーにとって、ちょっとわかりにくいのが、pointとclaimableTokenの両方を持っているところかと思います
  • pointは減ることはなく、ロールと連動し、ロールによって特定機能を有効にする拡張性を備えて設計しています
  • claimableTokenはQuest(クイズに回答する)ともらえるトークン量です
  • 実際に受け取ると減額します
  • Listen To Earn の核になる部分です

おわり

以上で解説おわりです。

Discussion