💓

【dApps/Blockchain】心拍数をもとにNFTアートを生成するアプリ作った

2022/03/13に公開約17,000字

はじめに

Blockchain/NFT、非常にホットですよね!
2021年3月にはTwitter創業者のJack Dorsey氏が出品した初ツイートが約3億円で落札されたり、人気コレクションBAYCのNFTが約5億円で落札されたり、異常なほどお金が流れている分野です。

NFTはブロックチェーンという技術を使用しており、エンジニアとしてはこのビックウェーブに乗っておきたい気持ちありますよね。

私は主にGoやReact、AWSあたりをやることが多く、ブロックチェーン技術には縁もゆかりもありませんでした。(仮想通貨は買ったりしてた)
しかし、なんとかしてこの盛り上がりに波乗りしたいと思い、去年の9月くらいからちょこちょこ空いた時間を使い、NFTアートを生成するアプリ(dApps)を作ってみました。

この記事では、アプリの仕様や技術構成をはじめ、アプリ開発を通じて得た知見を共有できればと思います!

と言ってもまだ9割ほどの完成ですが。。
アート生成に関するロジック以外、心拍数の取得やBlockchainとの連携部分は完成しているので、その辺りを共有できればと思います。

dApps
Blockchainと連携するアプリのこと

読んだら幸せになれそうな人

  • 個人開発に興味のある人
  • NFTを買ってみたい人
  • Blockchainに取り組みたいけど何から始めたら良いかわからない人
  • Go/React/Solidity/Renderに興味のある人
  • モダンなアプリ開発を知りたい人

サービス概要

以下の流れで、心拍数からNFTアートを生成します。

  • 心拍数を取得する日付を選択
  • Fitbitからその日付の心拍数を取得
  • その心拍数に基づいてアート(画像)を生成
  • 生成されたアートをBlockchain(Ethereum)上にmint

mint
ざっくり言うと、Blockchain上にデータを保存すること

Fitbit
スマートウォッチを製造しているメーカー、およびそのデバイス
スマートウォッチから取得したデータを提供するAPIも提供している

Ethereum
Blockchainで一番メジャーなチェーン

技術構成概要

まあまあモダン、というか最近だともはや標準?
インフラのRenderは日本だとあんまり馴染みないかもですね。

  • フロント: React/TypeScript
  • バックエンド: Go
  • SmartContract: Solidity
  • インフラ: Render

NFTとは?

もしわからない場合はこちらを参照ください!

https://www.sbbit.jp/article/fj/60992

こんなのつくりました

webページ

もしFitbitデバイスをお持ちであれば、無料でアート生成まで試せるので良かったら試してみてください!

(デザインはいつか頑張る😇)

https://heart-rate.art/

デモ動画

ちゃんとしたアート生成はできていなくて、今はまだ試しに作った3色国旗が生成されるだけですが。。
一応、心拍数の値に応じて色が変わるようにはなっています。

・心拍数を取得してアートを生成するまで

・生成したアートをmintするまで

Twitter

面白そうと思って頂けたら、Twitterフォローして頂けると喜びます!!

https://twitter.com/heart_rate_art

サービスについて

概要 (再掲)

以下の流れでNFTアートを生成します。

  • 心拍数を取得する日付を選択
  • Fitbitからその日付の心拍数を取得
  • その心拍数に基づいてアート(画像)を生成
  • 生成されたアートをEthereum上にmint

狙い

NFTアートは所有者のアイデンティティの一部と見なされることがあり、そこへ身体データを混ぜることで、よりアイデンティティみが増すのではないかと考えました。

  • 現在、市場には既に様々なNFTアートで溢れかえっている
  • 一般人である自分が普通にただの画像をNFTアートとして売り出しても全く真新しさがない
    • そもそも絵描けない
  • 身体データから生成されたアートであれば新鮮味がありそう
    • Web3、メタバース時代において、NFTはアイデンティティとなるアイテム
    • そんなアイテムに身体データを混ぜれば、さらにアイデンティティみが増すのではないか

〇〇を選択した理由

こんな感じで検討しました。

Fitbitを選択した理由

  • 世界シェア2位
  • APIを提供しており、開発しやすい
  • Apple Watchは世界シェア1位であるが、、
    • APIを提供しておらず、身体データを取得するのが(ユーザにとって)一手間かかりそうだった
    • そもそも自分がAndroid派。Apple Watchの使用にはiPhoneが必須

心拍数を選択した理由

  • スマートウォッチから取得できるデータの中で、一番アイデンティティっぽかった(身体性が強い)
    • 他には睡眠データ、運動データなどが取得できる

エンジニアとしての目的

こんな感じのことをうっすら考えてました。

  • SmartContract/Solidityの習熟のために、自分で一からSmartContractを書きたい
  • 単に画像をNFTにするだけなら、OpenSea, Manifoldあたりを使えばノーコードでできてしまう
    • 開発不要なので技術習得にはつながらない
  • LINE BOT/LINEミニアプリくらいしか個人開発をしたことがなく、よりもっとサービスっぽいものを作ってリリースしてみたい

技術構成

ここからは技術構成について説明したいと思います。

技術視点で見た流れ

先ほど紹介したサービスの概要について、技術面にフォーカスして説明すると以下のようになります。
あまり馴染みのない単語も出てきますが後で解説します。


※あくまでざっくりとしたフローです。大枠だけ理解頂ければ。。

  • ユーザ: Generateボタンをクリック
  • バックエンド: Fitbit APIを利用して、指定された日付の心拍数を取得
  • フロント: 心拍数を受け取り、それを基にアート生成
  • ユーザ: Metamaskを接続
  • フロント: ユーザのウォレットアドレスを取得
  • ユーザ: mintボタンをクリック
  • バックエンド: アート画像とmetadataをIPFSにアップロード
  • SmartContract: ユーザのウォレットアドレスとmetadataを基にNFTをmint

当たり前といえばそうなのですが、基本的にはフロントが起点になっています。フロントがバックエンドやSmartContractとやり取りを行います。

これ以降は、このそれぞれについて解説していきます。

Fitbit API

OAuth2認証を利用して、ユーザの心拍数を取得します。

OAuth2とは

こちらが参考になりました。

https://tech-lab.sios.jp/archives/25470
https://qiita.com/TakahikoKawasaki/items/200951e5b5929f840a1f

実装

Goではoauth2というパッケージがあり、これを使うことであまり難しいことを考えずに実装できるようになっています。
ただ認証に関わる箇所なので、きちんと理解した上で実装した方が良いと思います。

https://github.com/golang/oauth2

Fitbitはこんな感じでAPI Referenceが充実しているので、比較的容易に実装することができました。

https://dev.fitbit.com/build/reference/web-api/intraday/get-activity-intraday-by-date/

心拍数からアート生成

ここはまさに開発中のところです。
技術選定や仕組み作りは済んでいるので、その辺りを書いていきます。

p5.js

アートの描画にはp5.jsというライブラリを使いました。
こちらはジェネラティブアート生成のデファクトスタンダードになっているProcessingというライブラリ(言語?)のJavaScript版です。

ジェネラティブアート
ざっくり言うと、プログラミングでお絵描きすること(本当はプログラミングに限らないらしい)

p5.jsはReact/TSで使えるようにもなっているので、そちらを使うと便利でした。
もちろんプレーンなhtml/js環境でも動作します。

https://qiita.com/shunp/items/05fe060fa37ae6f4b217

3Dアートを生成するのであれば、three.jsあたりも良さそうでした。(やってないけど)

実装

3色国旗を生成するコードは以下の通りです。
createColorCodeMapFromHeartRates関数を呼び出す際に、引数heartRatesにGoから受け取った心拍数の配列を格納しています。
Fitbit APIを利用して、Goでは指定した日付の1分おきの心拍数を取得しています。

コード
sketch.ts
import p5 from "p5";

interface ColorMap {
    [index: string]: string
}

const arraySplit = <T = object>(array: T[], n: number): T[][] => {
    return array.reduce((acc: T[][], c, i: number) => (i % n ? acc : [...acc, ...[array.slice(i, i + n)]]), []);

}

const createColorCodeMapFromHeartRates = (heartRates: number[]): ColorMap => {
    const len = Math.ceil(heartRates.length / 3);
    const eachRates = arraySplit(heartRates, len);
    const sums = eachRates.map((rates) =>
        rates.reduce((sum, i) => sum + i, 0));
    let map: ColorMap = {};
    map["top"] = `#${sums[0].toString(16)}00`;
    map["middle"] = `#${sums[1].toString(16)}00`;
    map["bottom"] = `#${sums[2].toString(16)}00`;
    return map;
}

const sketch = (heartRates: number[]) => {
    const colorMap = createColorCodeMapFromHeartRates(heartRates);
    return (p: p5) => {
        p.setup = () => {
            p.createCanvas(315, 195);
        }
        p.draw = () => {
            p.fill(colorMap["top"]);
            p.rect(10, 10, 300, 60);
            p.fill(colorMap["middle"]);
            p.rect(10, 70, 300, 60);
            p.fill(colorMap["bottom"]);
            p.rect(10, 130, 300, 60);
        }
    }
};
export default sketch;

Metamaskと連携してユーザのウォレットアドレスを取得

ここからいよいよBlockchainっぽくなってきます。
NFTの送り先を特定するために、Metamaskを使います。

Metamaskとは

仮想通貨のお財布です。
仮想通貨も通貨なので、当然それを入れる仮想のお財布が必要になります。
このお財布にはビットコインなどの仮想通貨はもちろん、NFTなども入れることができます。

ChromeのExtensionとして提供されています。

参考

https://fisco.jp/media/metamask-about/

実装

こんな感じでアドレスを取得できます。
Reactの場合、useStateを使ってアドレスを保持しておくと、取り回しが良いと思います。

const [currentAccount, setCurrentAccount] = useState("");

const connectWalletHandler = async () => {
    const { ethereum } = window;
    if (!ethereum) {
        alert("Please install Metamask!");
        return;
    }
    try {
        const accounts = await ethereum.request({ method: "eth_requestAccounts" });
        if (accounts.length === 0) {
            console.log("No authorized account found");
            return;
        }
        account = accounts[0];
        setCurrentAccount(account);
    } catch (e) {
        console.log(e);
    }
}

const connectWalletButton = () => {
    return (
        <Button onClick={connectWalletHandler} color="primary">
            Connect Wallet
        </Button>
    )
}

ユーザがMetamaskをインストールしている場合、グローバル変数windowethereum変数が入っています。なのでまずはこいつを取り出します。
ユーザがMetamaskをインストールしていない場合を考慮し、if文でearly returnしておきます。

ethereum.request({ method: "eth_requestAccounts" })とすることで、実際にアドレスを取得することができます。
配列形式なので、先頭のアドレスを取得して状態に保存します。

Metamaskでは複数のアカウントを作ることができ、使用するアカウントを使い分けることができます。
配列の先頭に現在有効なアカウントのアドレスが入っています。

アート画像とmetadataをIPFSにアップロード

IPFSとは

ざっくり言うと、分散型のS3みたいな感じです。
ファイルのアップロード場所で、NFTでよく使われている、くらいの認識で良いと思います。

参考

https://nonentropy.jp/blog/ipfsとは?/

IPFSを使う理由

前提として、Ethereum上にデータ(文字列、画像、なんでも)を保存する際にはガス代という手数料がかかります。
このガス代は保存するデータの容量が大きければ大きいほど高くなります。
なので、画像を直接保存しようとするととてもコストがかかってしまいます。

そのため、NFTアートをmintする際には、画像とmetadataをIPFSにアップロードしておいて、そのリンクだけをmintすることが(おそらく)一般的です。
対して、画像(をはじめとするあらゆるデータ)を丸ごとmintすることをフルオンチェーンと言ったりします。そのまんまですね。

実際にmintされているデータ

以下のNFTを用いて具体的に説明します。
こちらはOpenSeaというNFTマーケットのwebページです。


https://testnets.opensea.io/assets/0x3142981ac639ea5bd948163069d17ed293f416d5/4

こちらのNFTは、以下のjson形式のmetadataのURLがBlockchain上にmintされたものです。

https://gateway.pinata.cloud/ipfs/Qmdm1zRAfYd63kc8U5z2y5tLpNSdgmTQUFfLadUART8rHn
上記URLの中身
{
    "name": "yagi_eng",
    "description": "This art was generated based on yagi_eng's heart rate on 2022-02-23",
    "image": "https://gateway.pinata.cloud/ipfs/QmcgK5E74xJVSh38Zp6NhS488mxRVscVSwj6ZSVZkFKL8D"
}

各プロパティは名前の通りで、nameがNFTの名前、descriptionがNFTの説明、imageがNFTアート画像のURLです。
このBlockchain上にmintされたmetadataを、OpenSeaが自動で解釈してくれて上記のようにweb上で確認することができます。
作成したNFTをOpenSeaに登録するといった手順は必要なく、NFTをBlockchain上にmintした時点でそのNFTをOpenSea上で閲覧することが可能です。

metadataに画像のURLを埋め込む必要があるので、画像、metadataの順番でアップロードする必要があります。

Pinata

今回はPinataというIPFSを提供するサービスを利用しました。
PinataはAPIを提供しており、API経由でjsonファイルや画像ファイルをアップロードすることができます。

アップロードしたファイルには、CIDというユニークなIDが割り当てられます。

https://docs.pinata.cloud/

実装

画像とmetadata生成に必要な情報をフロントからバックエンドに送信し、バックエンドがアップロードを行います。

フロントから直接PinataのAPIを実行するのではなく、一度バックエンドを介している理由は、PinataのAPIキーを保持しておく必要があるためです。
FitbitのAPIに関しても同じ理由です。なので、基本的にこのサービスでのバックエンドの役割は第三者サービスAPIのwrapperという感じです。

APIの実行には以下のライブラリを使います。

https://github.com/wabarc/ipfs-pinner

解説するほどではないですが、このライブラリを使うと以下のように数行でアップロードすることができます。便利。

// ${filePath} アップロードするファイルの置き場所
func uploadToPinata(filePath string) (string, error) {
	pnt := pinata.Pinata{
		Apikey: os.Getenv("PINATA_API_KEY"),
		Secret: os.Getenv("PINATA_API_SECRET"),
	}
	cid, err := pnt.PinFile(filePath)
	if err != nil {
		return "", err
	}
	return cid, nil
}

metadataのjsonファイルの生成はバックエンドで行っており、こんな感じです。
構造体metadatajson.MarshalIndent, ioutil.WriteFileでjsonファイルにして、それをuploadToPinataに渡しています。

// ${req} フロントから受け取ったmetadata生成に必要な情報
// ${artCID} 画像ファイルのCID
func uploadJSONToPinata(req uploadRequest, artCID string) (string, error) {
	metadata := metadata{
		Name:        req.UserName,
		Description: resolveMetadataDescription(req),
		Image:       resolvePinataURL(artCID),
	}

	fileName := fmt.Sprintf("%s.json", req.UserName)
	file, _ := json.MarshalIndent(metadata, "", " ")
	if err := ioutil.WriteFile(fileName, file, 0644); err != nil {
		return "", err
	}
	defer os.Remove(fileName)

	cid, err := uploadToPinata(fileName)
	if err != nil {
		return "", err
	}
	return cid, nil
}

NFTをmint

いよいよ本題、Blockchainと連携する部分です。

書いておいてなんですが、「NFTをmint」というのは正確には正しい表現ではないです。
画像とmetadataをmintすることで、それがNFTとなります。

基本的には、フロントからSolidityのコードを呼ぶだけです。

SmartContract/Solidityとは

SmartContractとは、簡単に言うとBlockchain上で動作するプログラムのことです。
このプログラムがBlockchainにデータの読み書きをしたりします。
SmartContractを使うことにより、NFTを定義することもできます。SmartContractがNFTの管理を行っている、というイメージです。(ちょっと想像しづらいですかね。。)

Blockchainには一番有名なEthereumをはじめ、Solanaなど色々なチェーンがあります。
今回はEthreumを使っており、Ethreum上でSmartContractを実装する際に使うプログラミング言語がSolidityです。

参考

https://trade-log.io/column/1313

実装(SmartContract)

コードの全容は以下の通りです。

コード
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.3;

import "@openzeppelin/contracts/utils/Counters.sol";
// 本当はopenzeppelinのコードを少し自分でカスタマイズしたものを使ってる、この記事では割愛
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";

contract HeartRateArt is ERC721URIStorage {
    using Counters for Counters.Counter;
    Counters.Counter private _tokenIds;

    // Mapping owner address to mint count
    mapping(address => uint256) private _counts;

    // addresses that are the target of airdrop
    mapping(address => bool) private _airdropList;

    constructor() ERC721("HeartRateArt", "HEART") {}

    function mintNFT(address _to, string memory _tokenURI)
        public
        payable
        returns (uint256)
    {
        // own limit
        uint256 ownLimit = 20;
        require(_counts[_to] < ownLimit, "One address can mint only 20 NFTs");

        // pricing
        uint256 price = calcPrice(_to);
        if (_isAirDropAddress(_to)) {
            _airdropList[_to] = false;
        }
        require(price <= msg.value, "Ether value sent is not correct");

        // mint
        _tokenIds.increment();
        uint256 tokenId = _tokenIds.current();
        _mint(_to, tokenId);
        _setTokenURI(tokenId, _tokenURI);

        // add count
        _counts[_to] += 1;

        return tokenId;
    }

    function setAirdropList(address[] memory _list) public onlyOwner {
        for (uint256 i = 0; i < _list.length; i++) {
            _airdropList[_list[i]] = true;
        }
    }

    function calcPrice(address _address) public view returns (uint256) {
        uint256 campaign = 50;
        uint256 price = 20000000000000000; //0.02 ETH
        if (_tokenIds.current() < campaign) {
            price = 10000000000000000; //0.01 ETH
            if (_isAirDropAddress(_address)) {
                price = 0;
            }
        }
        if (_address == owner()) {
            price = 0;
        }
        return price;
    }

    function _isAirDropAddress(address _address) private view returns (bool) {
        return _airdropList[_address];
    }

    function withdraw() public onlyOwner {
        uint256 balance = address(this).balance;
        payable(owner()).transfer(balance);
    }

    // WE NEVER DELETE VALID NFTS. This function exists just for preventing from malicious minting.
    function burnInvalidNFTs(uint256[] memory _ids) public onlyOwner {
        for (uint256 i = 0; i < _ids.length; i++) {
            _burn(_ids[i]);
        }
    }
}

色々試行錯誤してこれに行き着いたのですが、全部説明するとかなり長くなってしまうので、今回は概要のみにとどめます。
(その記事も需要があればいつか書きたい。。)

ちなみに、SmartContractのコードを公開するのは、Blockchain業界での慣習だったりします。
少し話が逸れるのですが、EtherscanというEthereum上のトランザクションを誰でも閲覧できるサイトがあり、そこでSmartContractのコードを閲覧することもできます。

例えば、人気NFTのBAYCのSmartContractはこちらから閲覧することができます。

https://etherscan.io/address/0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d#code

実装について、自分で一から実装する必要はなく、OpenZeppelinという会社が提供しているライブラリを使用することで、超簡単にNFTのSmartContractを実装することができます。
以下のようにファイルの先頭でimportします。

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

もちろんただ、importするだけでなく、使うパッケージをあらかじめnpm installする必要があります。(npmを使うんです!)

$ npm install @openzeppelin/contracts

mintを行っている関数をわかりやすく簡略化したものは以下です。
Solidityにも継承の概念があり、OpenZeppelinからimportしたContractを継承し、そこに定義されている_mint, _setTokenURIを呼ぶだけです。かなり楽ですね。

function mintNFT(address _to, string memory _tokenURI)
    public
    returns (uint256)
{
    // mint
    _tokenIds.increment();
    uint256 tokenId = _tokenIds.current();
    _mint(_to, tokenId);
    _setTokenURI(tokenId, _tokenURI);

    return tokenId;
}

このあたり細かく知りたかったり、実際に自分で実装してみたい人は以下の公式チュートリアルがかなり参考になります。
私もこのチュートリアルからはじめました。

https://ethereum.org/en/developers/tutorials/how-to-write-and-deploy-an-nft/

実装(フロント)

web3.jsというライブラリを使ってSmartContractと通信をします。
なんとなく雰囲気が伝われば良いかと思いますが、SmartContractのアドレスcontractAddressやABI(Application Binary Interface, SmartContractのinterface的なの)をnftContractに引き渡した上で、web3.eth.sendTransactionで実際にトランザクションを送ります。
(SmartContractもウォレットと一緒で、アドレスを持っています)

このあたりもまた解説すると長いのですが、需要がありそうであれば別記事に書きたいと思っています。
先ほど紹介したチュートリアルにも解説があるので、そちらを参照しても良いかと思います。

簡略化した実装
import contract from "contracts/HeartRateArt.json";

const web3 = new Web3(Web3.givenProvider);
const abi = (contract.abi as any) as AbiItem;
const contractAddress = "0x3142981Ac639ea5bD948163069d17eD293F416D5";
const nftContract = new web3.eth.Contract(abi, contractAddress) as any as HeartRateArt;

// ${account} is user's wallet address
export const mintNFT = async (
    account: string,
) => {
    const nonce = await web3.eth.getTransactionCount(account, "latest");

    //the transaction
    const tx = {
        from: account,
        to: contractAddress,
        nonce: nonce,
        data: nftContract.methods.mintNFT(account, "tokenURI here").encodeABI(),
    }
    web3.eth.sendTransaction(
        tx,
        (err: Error, hash: string) => {
            if (!err) {
                alert(`Minted! See transaction: https://rinkeby.etherscan.io/tx/${hash}`);
            } else {
                const msg = "Something went wrong when submitting your transaction";
                alert(msg);
                console.log(msg, ":", err);
            }
        }
    )
}

インフラ

今回はRenderというサービスを使いました。

https://render.com/

個人的にはHerokuの上位互換といった印象です。特別な設定ファイルを必要とせず、GUIでポチポチやれば1,2分でバックエンドやフロントエンドをデプロイできるので、かなり便利だと思います。
無料プランだと、バックエンドサーバの場合15分アクセスがないとコンテナが停止したりしますが、ドメインの設定は無料でできます。
フロントエンドサーバ(というかstatic site)の場合、コンテナの停止などはなく、いつでもすぐにアクセスできます。

Netlifyと悩んだのですが、バックエンドとフロントエンドの両方をホストしたく、単一のサービスで管理したかったため、Renderを選びました。
フロント単体であればやはりNetlifyの方が便利かもしれません。

https://heart-rate.art/

HeartRateArtでは、ユーザがサイト上でGenerateボタンを押すと、バックエンドのAPIをcallする仕組みになっています。
このAPI call時にコンテナが起動し始めるのでは、ボタンを押してからコンテナが起動するまでに10秒弱かかってしまうので、ユーザを待たせることになります。

そのため、HeartRateArtでは、フロントにアクセスがあった時、バックエンドにpingを打つようにしました。
こうすることで、ユーザがGenerateボタンを押すまでにコンテナが起動するようにしています。
(もちろんユーザがサイトにアクセスして、その直後にボタンを押すとラグが生まれますが、まあ想定しなくていいケースなのかなと。)

実装にかかった工数

企画・用件定義を除くと、大体半年くらいを実装に費やしました。
ちゃんと時間計測してないですけど、週1~1.5日くらい取り組んでたとすると、以下の間をとって1.5人月(240時間)くらいですかね。

0.2~0.3 人月 * 6 ヶ月 = 1.2 ~ 1.8 人月

これに自分の時間単価をかけると、、少なくはない金額を投資したことになるなと思いました😇

さいごに

かなり駆け足のつもりでしたが、それなりのボリュームになりました。。
NFT・dAppsへの理解は深まったでしょうか?

今回紹介しきれていないような技術要素も多いので、もし気になる点があれば気軽にTwitterとかで質問ください!

また、Fitbitユーザしか使えないですが、もしお持ちでしたら無料でアート生成できるので良かったら試してみてください。
(今は三色国旗しか生成されませんが、、)

https://heart-rate.art/

さいごに、現在絶賛アート生成ロジックを実装中でして、最新情報等はTwitterの方で発信しているのでよければフォローお願いします!!!

https://twitter.com/heart_rate_art

ちなみに私個人のTwitterアカウントはこちらです。
モダンな技術習得やサービス開発の様子を発信したりしているので、こちらも良かったらチェックしてみてください!

https://twitter.com/yagi_eng/status/1503144502034563074

Discussion

ログインするとコメントできます