NFTオークションを実装してみた

以下のドキュメントを元にNFTの鋳造と、鋳造時に発行したトークンに対してオークションを行えるようにしました。流れを把握しやすいようコンテンツのアップロードとコントラクトのデプロイ、書き込みをすべてクライアント側のJavascriptで行っていますが、実際にオークションシステムとして利用されるためにはゲートウェイを用意してそこでコントラクトの書き込み等を行うべきかと思います。 docs.ipfs.io solidity-jp.readthedocs.io

始めに

シーケンス

今回実装するオークションシステムですが出品から入札、終了まで以下の流れになっています。

  1. 出品
    1-1. 出品者がコントラクトをデプロイする
     1-1-1. コントラクトに出品者情報をセット
    1-2. 出品者がコンテンツを出品する
     1-2-1. 出品者がコンテンツをIPFSにアップロードする
     1-2-2. 出品者がアップロードしたコンテンツのURLに対してNFTを鋳造する
     1-2-3. 出品者が始値と出品時間をコントラクトに書き込む
     1-2-4. 出品者が初期のトークン所有者を出品者にしてコントラクトに書き込む
  2. 入札
    2-1. 入札者はコントラクトとトークンを選択する
    2-2. 入札者は選択したトークンに対して送金する
     2-2-1. コントラクトの最高額入札者に入札時の金額を送金する
     2-2-1. コントラクトの最高額入札者に入札額を更新する
    2-3. トークン所有者は終了時間後にオークション終了を行う
     2-3-1. 出品者とトークン所有者に送金する
     2-3-2. 最高額入札者をトークン所有者にする

環境情報

動作確認時の環境情報になります。また、確認したコードは以下になります。

github.com

IPFS

IPFSはコンテンツのアップロード先に使用し、コントラクトに書き込むのはアップロード先のURLになります。これはコントラクトへ書き込むサイズに応じて決まるガス代を抑えるためになります。開発時はIPFS Desktopを利用していました、内部的にはipfs-goが起動しているようです。

docs.ipfs.io そのまま起動すると世界中のノードとつながるのですが、その必要はなかったのでipfsの設定ファイルを編集しほかのノードとつながらないように"Bootstrap": []としておきました。

  • ~/.ipfs/config
{
  "API": {
~省略~
  "Bootstrap": [],
~省略~

Ganache

コントラクトはイーサリアムネットワークにデプロイして書き込みが行えるようになるのですが、本番ネットワークだと実際のイーサリアムが必要になり、kovanなどのテストネットワークを使用するにしてもイーサリアムを送金してもらわないと使えないので、手軽に開発で使えるモックネットワークのGanacheを使用しました。

www.trufflesuite.com

Metamask

イーサリアムネットワークのウォレットの管理としてブラウザ拡張のMetamaskを使用しました。

chrome.google.com

MetamaskがインストールされているかどうかはJavascriptでweb3のオブジェクトが定義されているかどうかで判断できます。

const isMetamaskInstalled = () => web3 !== 'undefined';

Solidity

今回はイーサリアムのモックネットワークで動作確認を行うのでイーサリアムコントラクト記述言語のSolidityを使います。ビルドツールとしてtruffleを使うのですが、こちらはnpmから起動することが出来ます。 以下のようにscriptsを設定しておいたらnpm run truffleでsolidityのコンパイルが行えます。

{
~省略~
  "scripts": {
    "truffle": "truffle compile",
~省略~
    "truffle": "^5.4.6",
~省略~

Solidityのソースフォルダやビルド先はtruffle-config.jsで指定できると思いますがデフォルトだとSolidityのソースフォルダはcontractsでビルド先はbuild/contractsのようです。SolidityをビルドしたらJsonファイルが出力され、このJsonファイルはJavascript側で参照するので、Javascriptをビルドする前にSolidityのビルドを行っておきます。

ERC721

NFTで利用されている規格としてERC721とERC155があるのですが、今回はERC721を利用しました。SolidityのERC721実装であるopenzeppelinもnpmでインストールします。

{
~省略~
  "dependencies": {
    "@openzeppelin/contracts": "^3.4.1",
~省略~

処理の流れ

出品

Metamasktとのアカウント連動

Metamaskをインストールしていたらethereum.request({ method: 'eth_requestAccounts' })でアクティブなアカウントを取得できるので、定期的に呼び出してアカウント情報を連動させるようにします。

RPC API | MetaMask Docs

  • src/app.js
    init() {
        const syncAccount = () => {
            if (syncAccountTimer) {
                clearInterval(syncAccountTimer);
            }
            syncAccountTimer = setInterval(() => {
                // Metamaskのアクティブなアカウントと同期させる
                ethereum.request({ method: 'eth_requestAccounts' }).then(accounts => {
                    const { account } = this.state;
                    if (account != accounts[0]) {
                        this.set(Object.assign(this.genInitState(), {
                            account: accounts[0]
                        }));
                    }
                });
            }, 1000);
        }
~省略~
        syncAccount();
~省略~
    }

以下では動作確認のためMetamask上でのアクティブなアカウントの公開鍵を表示しています。 f:id:steavevaivai:20210911161749g:plain

コントラクトのデプロイ

コントラクトのデプロイではSolidityのビルド結果のJsonJavascript側で読み込んで、それからweb3.jsを使ってイーサリアムネットワークにデプロイします。デプロイ先のイーサリアムネットワークはMetamaskで選択中のものにしています。const getWeb3js = () => isMetamaskInstalled() ? new Web3(web3.currentProvider) : undefined;現在は初期化時点でMetamaskで選択されていたイーサリアムネットワークに対してデプロイするようになっていますが、デプロイのタイミングで選択中のネットワークを取得して使うほうが良いです。

web3js.readthedocs.io

sendの引数としてMetamaskのアカウントを渡しています。

  • src/app.js
// コントラクトの読み込み
const itemABI = require('../build/contracts/Item.json');
const Web3 = require('web3');

const isMetamaskInstalled = () => web3 !== 'undefined';
const getWeb3js = () => isMetamaskInstalled() ? new Web3(web3.currentProvider) : undefined;
// Metamaskで選択中のネットワークに対してコントラクトのデプロイを行います。開発中はGanacheに対してデプロイして確認しています。
let web3js = getWeb3js();
~省略~
    deploy(account) {
        const contract = new web3js.eth.Contract(itemABI.abi);
        logBox.push("create contract.");
        contract.deploy({ data: itemABI.bytecode, arguments: [] })
            .send({
                from: account,
                gasPrice: 20000000000
            }, (error, transactionHash) => { })
            .on('error', (error) => {
                console.info(error);
            })
            .on('transactionHash', (transactionHash) => { })
            .on('receipt', (receipt) => {
                logBox.push("receipt: " + receipt.itemABI);
            })
            .on('confirmation', (confirmationNumber, receipt) => { })
            .then((deployedContract) => {
                logBox.push(`create contract success: address[${deployedContract.options.address}]`);
                const { contracts } = this.state;
                contracts[account] = deployedContract.options.address;
                this.set({ contracts: contracts });
                this.listen(deployedContract);
            });
    }

web3.jsでデプロイした後はコントラクトの以下のコンストラクタが実行され、デプロイ者をコントラクトの所有者にセットしています。

  • contracts/Item.sol
    constructor() public ERC721("Item", "ITM") {
        contractOwner = msg.sender;
        emit ContractCreate(contractOwner);
    }

コントラクトの所有者をセットすることで、このコントラクトに対して出品が行えるのをコントラクトの所有者のみにすることが出来ます。

  • contracts/Item.sol
    function mintToken(
        string memory metadataURI,
        uint256 price,
        uint256 time
    ) public returns (uint256) {
        require(
            contractOwner == msg.sender,
            "Mint is not Allowed except Contract Author."
        );
~省略~

f:id:steavevaivai:20210911162149g:plain

出品

出品の処理の流れとして、IPFSへのコンテンツアップロードし、次にコントラクトへの書き込みを行います。

以下ではsaveToIpfsを2回呼び出していますが、まず画像のみをIPFSのアップロードし、次に画像のURLとタイトル、説明を含めた情報をメタデータとしてIPFSにアップロードしています。それからメタデータのURLと出品時間、始値mintに渡してコントラクトへの書き込みを行っています。

  • src/app.js
    async makeNft(account, file, name, description, time, price) {
        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, time, price);
            }
        }
    }

IPFSへのアップロード

ipfs-http-clientを使ってIPFSにコンテンツをアップロードします。IPFSアップロードはcidが発行されまして、コンテンツのハッシュ値になっており同じコンテンツには同じcidが返されます。

  • src/app.js
// ローカルのIPFSノードに対してファイルアップロード
const IPFS_API_URL = "/ip4/127.0.0.1/tcp/5001";
const { create: ipfsHttpClient } = require('ipfs-http-client');
const ipfs = ipfsHttpClient(IPFS_API_URL);
// ローカルのノードを他のノードにつながらないようにする設定とし、IPFSのGatewayもローカルに向けておく
const IPFS_GATEWAY_URL = "http://127.0.0.1:8080";
~省略~
    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)
        }
    }

例えば以下のように画像をIPFSにアップロードした場合、cidはbafybeieofefmqixyzn22pz3pm2irylqj63pmkgsffqfrzl7bgw7a67aopaになり、コンテンツのURLはhttp://127.0.0.1:8080/ipfs/bafybeieofefmqixyzn22pz3pm2irylqj63pmkgsffqfrzl7bgw7a67aopaになります。今回はほかのノードにつながらないようにしているのでコンテンツのURLとしてローカルを指定していますが、他のノードにつながるようになっているのであれば、パブリックなドメイン名でアクセスできるはずです。 f:id:steavevaivai:20210905140053p:plain

NFTの鋳造

NFTの鋳造およびコントラクトへ出品情報(出品時間、始値)を呼び出すJavascript側の処理は以下になります。実際に呼び出しているところはdeployContract.methods.mintToken(metadataURI, web3js.utils.toWei(price.toString(), "ether"), time).send({ from: account })の部分で、始値についてはEtherumの最小単位であるweiに変換して呼び出しています。

  • src/app.js
    mint(account, metadataURI, time, price) {
        logBox.push("mint.");
        const { contracts } = this.state;
        let deployContract = new web3js.eth.Contract(itemABI.abi, contracts[account]);
        deployContract.methods.mintToken(metadataURI, web3js.utils.toWei(price.toString(), "ether"), time).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);
                tokenIds[contracts[account]] = Array.prototype.concat(tokenIds[contracts[account]] || [], tokenId);
                this.set({ tokenIds: tokenIds });
            })
            .on("error", (error) => {
                console.error(error);
            });
    }

この時に呼び出されるSolidityのコードは以下になります。トークンの発行を行っているのは_tokenIds.increment();の部分になるのですが、今回はただのカウント値を使っています。_mint(msg.sender, newTokenId); の部分はERC721実装であるopenzeppelnで定義されているのですが、トークンの所有者をセットしています。それから_setTokenURI(newTokenId, metadataURI);もopenzeppelinで定義されており、メタデータURIをセットしています。それからトークンID毎での入札額、入札終了時刻、終了フラグをセットしemit MintEventでイベントを発行しトークンIDを返しています。

  • contracts/Item.sol
    Counters.Counter private _tokenIds;
~省略~
    function mintToken(
        string memory metadataURI,
        uint256 price,
        uint256 time
    ) public returns (uint256) {
        require(
            contractOwner == msg.sender,
            "Mint is not Allowed except Contract Author."
        );

        _tokenIds.increment();

        uint256 newTokenId = _tokenIds.current();
        _mint(msg.sender, newTokenId);
        _setTokenURI(newTokenId, metadataURI);

        // 現在の入札額をセット
        _highestBid[newTokenId] = price;
        // 入札終了時刻をセット
        _auctionEndTime[newTokenId] = now + time * 60;
        // オークション終了フラグをセット
        _auctionEnd[newTokenId] = false;

        emit MintEvent(
            newTokenId,
            msg.sender,
            metadataURI,
            _highestBid[newTokenId],
            _auctionEndTime[newTokenId]
        );
        return newTokenId;
    }

Solidity側で発行したイベントはJavascriptで受け取ることができます。ここではcontract.events.allEventsでは呼び出す以前に発行された過去のイベントも受け取ることが出来ます。

  • src/app.js
    listen(contract) {
        contract.events.allEvents({ filter: {} })
            .on("data", (event) => {
                console.info(event.returnValues);
                if (event.event == "MintEvent") {
                    const { tokenId, creater, metadataURI, price, auctionEndTime } = event.returnValues;
                    logBox.push(`mint event: tokenId[${tokenId}] creater[${creater}] metadataUri[${metadataURI}] price(eth)[${price}] auctionEndTime[${new Date(1000 * auctionEndTime)}]`);
                } else if (event.event == "HighestBidIncreased") {
                    const { mode, selectContract, selectTokenId } = this.state;
                    const { tokenId, bidder, amount } = event.returnValues;
                    if (mode == MODE_BIDDING && selectContract.toLowerCase() == event.address.toLowerCase() && selectTokenId == tokenId) {
                        this.set({
                            highestBidder: bidder,
                            price: Web3.utils.fromWei(amount, 'ether')
                        });
                    }
~省略~
            }).on("error", console.error);
    }

またデプロイ時に返されるトークンIDはJavascript側で以下のように受け取って利用しています。

tokenIds[contracts[account]] = Array.prototype.concat(tokenIds[contracts[account]] || [], tokenId);

f:id:steavevaivai:20210911162809g:plain

入札

出品までできたので、次は入札を行っていきます。

出品情報の取得

Javascript側ではコントラクト、トークンが選択されたらコントラクトのownerOftokenURIを呼び出してトークン所有者とメタデータURIを取得しています。それからgetInfoで出品者、現在の価格、終了時刻、最高額入札者、終了フラグを取得しています。ownerOftokenURIについてはopenzeppelinで定義されたものを呼び出しています。コントラクト上ではプライスの単位がweiなので、Javascript側ではETH単位にするためWeb3.utils.fromWei(ret[1], 'ether'),を呼び出しています。

  • src/app.js
    changeTokenId(selectContract, selectTokenId) {
        if (selectContract != UNSELECTED && selectTokenId != UNSELECTED) {
            let deployContract = new web3js.eth.Contract(itemABI.abi, selectContract);
            deployContract.methods.ownerOf(selectTokenId).call().then(tokenOwner => {
                this.set({ tokenOwner });
            });
            deployContract.methods.tokenURI(selectTokenId).call().then(metadataURI => {
                this.downloadMetadata(metadataURI);
            });
            deployContract.methods.getInfo(selectTokenId).call().then(ret => {
                this.set({
                    beneficiary: ret[0],
                    price: Web3.utils.fromWei(ret[1], 'ether'),
                    auctionEndTime: 1000 * ret[2],
                    highestBidder: ret[3],
                    auctionEnd: ret[4],
                    bidPrice: Web3.utils.fromWei(ret[1], 'ether')
                });
            });
        }
        this.set({ selectContract, selectTokenId });
    }

getInfoは自前で定義したものを使っています。

  • contracts/Item.sol
    function getInfo(uint256 tokenId)
        public
        view
        returns (
            address,
            uint256,
            uint256,
            address,
            bool
        )
    {
        return (
            contractOwner,
            _highestBid[tokenId],
            _auctionEndTime[tokenId],
            _highestBidder[tokenId],
            _auctionEnd[tokenId]
        );
    }

メタデータURIからコンテンツデータ取得

tokenURIで取得したメタデータURIをもとにコンテンツデータをセットします。

  • src/app.js
    downloadMetadata(metadataURI) {
        fetch(metadataURI)
            .then(response => response.json())
            .then(data => {
                this.setState({
                    title: data.name,
                    description: data.description,
                    imageUrl: data.imageUrl
                })
                console.log(data)
            });
    }

入札する

入札は以下で行っています。

  • src/app.js
    bidding() {
        const { account, selectContract, selectTokenId, bidPrice } = this.state;
        if (selectContract != UNSELECTED && selectTokenId != UNSELECTED) {
            let deployContract = new web3js.eth.Contract(itemABI.abi, selectContract);
            logBox.push(`send bid. account[${account}] contract[${selectContract}] tokenId[${selectTokenId}] bidPrice[${bidPrice}]`);
            deployContract.methods.bid(selectTokenId).send({ from: account, value: web3js.utils.toWei(bidPrice, "ether") })
                .on("receipt", (result) => {
                    console.info(result);
                })
                .on("error", (error) => {
                    console.error(error);
                });
        }
    }

入札時に呼び出されるSolidityは以下になります。requireを使ってオークションがまだ終了していないか、出品者でないか、最高額入札者でないかなどチェックしています。それからrequireの条件を満たしているのであれば、それまでの最高額入札者に返金を行い、入札の情報を更新しています。

  • contracts/Item.sol
    function bid(uint256 tokenId) public payable {
        require(
            now <= _auctionEndTime[tokenId] && !_auctionEnd[tokenId],
            "Auction already ended."
        );
        require(
            msg.value > _highestBid[tokenId],
            "There already is a higher bid."
        );
        require(
            _highestBidder[tokenId] != msg.sender,
            "You are already higher bidder."
        );
        require(ownerOf(tokenId) != msg.sender, "You are already Owner.");

        if (_highestBidder[tokenId] != address(0)) {
            // 現在の最高額入札者に返金
            _highestBidder[tokenId].transfer(_highestBid[tokenId]);
        }
        _highestBidder[tokenId] = msg.sender;
        _highestBid[tokenId] = msg.value;
        emit HighestBidIncreased(tokenId, msg.sender, msg.value);
    }

f:id:steavevaivai:20210911163208g:plain

オークション終了

トークン所有者は以下のようにオークション終了を呼び出すことが出来ます。

  • src/app.js
    auctionEnd() {
        const { account, selectContract, selectTokenId } = this.state;
        if (selectContract != UNSELECTED && selectTokenId != UNSELECTED) {
            let deployContract = new web3js.eth.Contract(itemABI.abi, selectContract);
            logBox.push(`send auction end. account[${account}] contract[${selectContract}] tokenId[${selectTokenId}]`);
            deployContract.methods.auctionEnd(selectTokenId).send({ from: account, value: 0 })
                .on("receipt", (result) => {
                    console.info(result);
                })
                .on("error", (error) => {
                    console.error(error);
                });
        }
    }

Solidity側は終了時刻が来ているか、すでに終了していないか、トークン所有者以外が呼び出していないかをチェックし、満たしているのであればトークン所有者に8割、出品者に2割送金し、トークン所有者の情報を更新し終了フラグをonにしています。初回はトークン所有者=出品者なので出品者に10割送金されます。

  • contracts/Item.sol
    function auctionEnd(uint256 tokenId) public {
        require(
            now >= _auctionEndTime[tokenId],
            "Not yet the aouction End time."
        );
        require(!_auctionEnd[tokenId], "Auction is already Ended.");
        require(
            ownerOf(tokenId) == msg.sender,
            "Only Token Owner can end the auction."
        );

        _auctionEnd[tokenId] = true;
        emit AuctionEnded(
            tokenId,
            _highestBidder[tokenId],
            _highestBid[tokenId]
        );

        if (_highestBidder[tokenId] != address(0)) {
            // トークン所有者に8割送金
            address payable _tokenOwner = address(uint160(ownerOf(tokenId)));
            _tokenOwner.transfer((_highestBid[tokenId] * 8) / 10);
            // 出品者に2割送金
            contractOwner.transfer((_highestBid[tokenId] * 2) / 10);

            // トークン所有者を更新
            _transfer(msg.sender, _highestBidder[tokenId], tokenId);
        }
    }

f:id:steavevaivai:20210911163652g:plain