NFTを鋳造してみた

以下のIPFSのページを参考にNFTの鋳造をしてみました。 docs.ipfs.io

鋳造というのはMint an NFT with IPFSのMint の訳で意味としては材料を溶かして型に流し込むやり方で製造方法のことのようで、硬貨を鋳造などに使われる単語のようです。また、そもそものNFTですが非代替性トークンと訳されていまして特徴としては重複のないトークンのようで、イーサリアムネットワークですでに実装されている規格としてERC721があります。こちらはイーサリアムネットワークのコントラクト記述言語であるSolidityにて利用することが出来ます。

github.com

またMint an NFT with IPFSのIPFSですが、コントラクトに書き込むサイズが多ければ多いほどガス代の金額が膨れ上がるため画像などの情報はIPFSに保存し、それからIPFSへのメタ情報保存時のCID(Content Identifiers)をコントラクトに書き込むことでガス代を抑えています。

確認環境

確認した実装は以下になります。 github.com

SolidityのビルドにはTruffle経由でsolcの"0.6.0"を使っています。@openzeppelin/contractsは"3.4.1"を使っています。IPFSのアップロード先としてはローカルにIPFSノードを立ち上げたものを使い、他のノードとつながらないようにするのでGatewayURLとしてもローカルのURLとなるようにしています。また確認用のイーサリアムネットワークはテストネットワークではなくMockネットワークのGanacheを使っていました。

実装内容

コントラクト

確認したコントラクトのコードは以下になります。NFT鋳造のシンプルなサンプルとしてカウント値をそのままトークンとして使っていたのそれに合わせています。mintTokenが呼び出されたらERC721の_mintを呼び出しトークンの所有者をセットしています、それから_setTokenURIメタデータURIをセットしています。

pragma solidity ^0.6.0;

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

contract Item is ERC721{
    using Counters for Counters.Counter;
    Counters.Counter private _tokenIds;

    event MintEvent(address player, string metadataURI);

    constructor() public ERC721("Item", "ITM") {}

    function mintToken(address player, string memory metadataURI)
        public
        returns (uint256)
    {
        emit MintEvent(player, metadataURI);

        _tokenIds.increment();

        uint256 newItemId = _tokenIds.current();
        _mint(player, newItemId);
        _setTokenURI(newItemId, metadataURI);

        return newItemId;
    }
}

ERC721のバージョン3.4.1の_mintの実装は以下になっていまして実際に_tokenOwnersトークンの所有者のセットと_holderTokens重複チェックのための使用済みトークンを保持しています。

    function     function _mint(address to, uint256 tokenId) internal virtual {
        require(to != address(0), "ERC721: mint to the zero address");
        require(!_exists(tokenId), "ERC721: token already minted");

        _beforeTokenTransfer(address(0), to, tokenId);

        _holderTokens[to].add(tokenId);

        _tokenOwners.set(tokenId, to);

        emit Transfer(address(0), to, tokenId);
    }(address to, uint256 tokenId) internal virtual {
        require(to != address(0), "ERC721: mint to the zero address");
        require(!_exists(tokenId), "ERC721: token already minted");

        _beforeTokenTransfer(address(0), to, tokenId);

        _holderTokens[to].add(tokenId);

        _tokenOwners.set(tokenId, to);

        emit Transfer(address(0), to, tokenId);
    }

それから_setTokenURIでは以下のように登録済みのトークンかどうかをチェックしたうえでtokenURIをセットしています。

    function _setTokenURI(uint256 tokenId, string memory _tokenURI) internal virtual {
        require(_exists(tokenId), "ERC721Metadata: URI set of nonexistent token");
        _tokenURIs[tokenId] = _tokenURI;
    }

セットしたtokenURIはtokenURIで取得しているようで、requireの条件を見てみると指定されトークンIDが存在するかどうかだけを見ているだけのようです。

    function tokenURI(uint256 tokenId) public view virtual override returns (string memory) {
        require(_exists(tokenId), "ERC721Metadata: URI query for nonexistent token");

        string memory _tokenURI = _tokenURIs[tokenId];
        string memory base = baseURI();

        // If there is no base URI, return the token URI.
        if (bytes(base).length == 0) {
            return _tokenURI;
        }
        // If both are set, concatenate the baseURI and tokenURI (via abi.encodePacked).
        if (bytes(_tokenURI).length > 0) {
            return string(abi.encodePacked(base, _tokenURI));
        }
        // If there is a baseURI but no tokenURI, concatenate the tokenID to the baseURI.
        return string(abi.encodePacked(base, tokenId.toString()));
    }

フロント

まずフロントの実装について以下のようにMetamaskのアカウント情報が取れたらコントラクトをデプロイしています。

    init() {
        const syncAccount = () => {
            if (syncAccountTimer) {
                clearInterval(syncAccountTimer);
            }
            syncAccountTimer = setInterval(() => {
                ethereum.request({ method: 'eth_requestAccounts' }).then(accounts => {
                    const { account, contract } = this.state;
                    if (account != accounts[0]) {
                        this.set({ account: accounts[0] });
                        if (!contract) {
                            this.contractDeploy();
                        }
                    }
                });
            }, 1000);
        }
        ~省略~
        syncAccount();
        readEventLog();
        tailEventLog();
    }

    contractDeploy() {
        const contract = new web3js.eth.Contract(itemABI.abi);
        logBox.push("create contract.");
        contract.deploy({ data: itemABI.bytecode, arguments: [] })
            .send({
                from: this.state.account,
                gasPrice: 20000000000
            }, (error, transactionHash) => { })
            .on('error', (error) => {
                console.info(error);
            })
            .on('transactionHash', (transactionHash) => { })
            .on('receipt', (receipt) => {
                // logBox.push("receipt: " + receipt.itemABI);
                // console.log(receipt.itemABI) // contains the new contract address
            })
            .on('confirmation', (confirmationNumber, receipt) => { })
            .then((deployedContract) => {
                logBox.push(`create contract success: address[${deployedContract.options.address}]`);
                this.set({ contract: deployedContract.options.address });
                this.listen(deployedContract);
            });
    }

それから以下でNFTの鋳造を行っているのですが"await this.saveToIpfs({ path: file.name, content: file })"で画像をIPFSにアップロードし、それから画像のURLを含めたメタデータを作成し"this.saveToIpfs({ path: 'metadata.json', content: JSON.stringify({ name, description, imageUrl: ${IPFS_GATEWAY_URL}/ipfs/${added_file_cid}/${file.name} }) }) "それもIPFSにアップロードし、最後にメタデータURIを"deployContract.methods.mintToken(account, metadataURI)."でNFTの鋳造をしていまして、".on("receipt", (result) => "でレスポンスとしてトークンを受け取っているのですが今回はただのカウント値です。

    making() {
        const { title, description, account, imgFile } = this.state;
        if (!account) {
            alert("アカウントが不明です")
        } else if (title.trim().length == 0 || description.trim().length == 0 || imgFile == null) {
            alert("入力が無効です")
        } else {
            logBox.push("make nft.");
            this.makeNft(account, imgFile, title, description);
        }
    }

    async makeNft(account, file, name, description) {
        logBox.push("upload ipfs to image.");
        const added_file_cid = await this.saveToIpfs({ path: file.name, content: file });
        if (added_file_cid) {
            const imageUrl = `${IPFS_GATEWAY_URL}/ipfs/${added_file_cid}/${file.name}`;
            logBox.push(`upload ipfs to image cid[${added_file_cid}] url[${imageUrl}]`);
            logBox.push("upload ipfs to metadata.");
            const metadata_cid = await this.saveToIpfs({ path: 'metadata.json', content: JSON.stringify({ name, description, imageUrl: `${IPFS_GATEWAY_URL}/ipfs/${added_file_cid}/${file.name}` }) })
            if (metadata_cid) {
                const metadataUrl = `${IPFS_GATEWAY_URL}/ipfs/${metadata_cid}/metadata.json`;
                logBox.push(`upload ipfs to metadata cid[${metadata_cid}] url[${metadataUrl}]`);
                this.setState({ added_file_cid: added_file_cid, added_metadata_cid: metadata_cid });
                this.mint(account, metadataUrl);
            }
        }
    }

    async saveToIpfs(detail, option = { wrapWithDirectory: true, cidVersion: 1, hashAlg: 'sha2-256' }) {
        try {
            const added = await ipfs.add(detail, option)
            return added.cid.toString();
        } catch (err) {
            console.error(err)
        }
    }

    mint(account, metadataURI) {
        logBox.push("mint.");
        const { contract } = this.state;
        let deployContract = new web3js.eth.Contract(itemABI.abi, contract);
        deployContract.methods.mintToken(account, metadataURI).send({ from: account })
            .on("receipt", (result) => {
                const { tokenIds } = this.state;
                const tokenId = result.events.Transfer.returnValues.tokenId;
                logBox.push(`mint tokenId[${tokenId}]`);
                console.info(result);
                this.setState({ tokenIds: tokenIds.concat(tokenId) });
            })
            .on("error", (error) => {
                console.error(error);
            });
    }

それからdeployContract.methods.tokenURI(currentToken)トークンIDを元にメタデータURIを取得しています。

    changeTokenId(index) {
        const { contract, tokenIds } = this.state;
        if (index != 0) {
            const currentToken = tokenIds[index];
            let deployContract = new web3js.eth.Contract(itemABI.abi, contract);
            deployContract.methods.tokenURI(currentToken).call().then(metadataURI => {
                this.downloadMetadata(metadataURI);
            });
        }
        this.set(Object.assign(this.initState(), { addressSelectedIndex: index }));
    }

動作確認

NFT鋳造時のイメージは以下のようになりました。画像のCIDは"bafybeieofefmqixyzn22pz3pm2irylqj63pmkgsffqfrzl7bgw7a67aopa"でGatewayURLが"http://127.0.0.1:8080"なので"http://127.0.0.1:8080/ipfs/bafybeieofefmqixyzn22pz3pm2irylqj63pmkgsffqfrzl7bgw7a67aopa"で画像にアクセスできます。 f:id:steavevaivai:20210822092028p:plain

メタデータのCIDは"bafybeihwsxzbgwf4vsyep2t5k7vhmont2jbaumpgg3lbdlilmtu3quon6i"なので"http://127.0.0.1:8080/ipfs/bafybeihwsxzbgwf4vsyep2t5k7vhmont2jbaumpgg3lbdlilmtu3quon6i"で以下のメタデータが取得できます。

{"name":"ブッポウソウ","description":"ブッポウソウ(仏法僧、Eurystomus orientalis)とは鳥綱ブッポウソウ目ブッポウソウ科に分類される鳥である。","imageUrl":"http://127.0.0.1:8080/ipfs/bafybeieofefmqixyzn22pz3pm2irylqj63pmkgsffqfrzl7bgw7a67aopa/鳥.jpg"}

NFT鋳造後はメタデータを取得し画像の表示が行えます。 f:id:steavevaivai:20210822093147p:plain