ブロックチェーン上で動的なSVGを作ろう
🔤 SVG 画像にランダムに単語を反映させよう
前回のレッスンでは、NFTをオンチェーンで作成するコントラクトを実装しました。
これから、3つのランダムな単語を動的に組み合わせてNFTを出力するコードを作成していきます。
下記のように、MyEpicNFT.sol
を更新していきましょう。
// MyEpicNFT.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;
// いくつかの OpenZeppelin のコ ントラクトをインポートします。
import "@openzeppelin/contracts/utils/Strings.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
// utils ライブラリをインポートして文字列の処理を行います。
import "@openzeppelin/contracts/utils/Counters.sol";
import "hardhat/console.sol";
// インポートした OpenZeppelin のコントラクトを継承しています。
// 継承したコントラクトのメソッドにアクセスできるようになります。
contract MyEpicNFT is ERC721URIStorage {
// OpenZeppelin が tokenIds を簡単に追跡するために提供するライブラリを呼び出しています
using Counters for Counters.Counter;
// _tokenIdsを初期化(_tokenIds = 0)
Counters.Counter private _tokenIds;
// SVGコードを作成します。
// 変更されるのは、表示される単語だけです。
// すべてのNFTにSVGコードを適用するために、baseSvg変数を作成します。
string baseSvg = "<svg xmlns='http://www.w3.org/2000/svg' preserveAspectRatio='xMinYMin meet' viewBox='0 0 350 350'><style>.base { fill: white; font-family: serif; font-size: 24px; }</style><rect width='100%' height='100%' fill='black' /><text x='50%' y='50%' class='base' dominant-baseline='middle' text-anchor='middle'>";
// 3つの配列 string[] に、それぞれランダムな単語を設定しましょう。
string[] firstWords = ["YOUR_WORD", "YOUR_WORD", "YOUR_WORD", "YOUR_WORD", "YOUR_WORD", "YOUR_WORD"];
string[] secondWords = ["YOUR_WORD", "YOUR_WORD", "YOUR_WORD", "YOUR_WORD", "YOUR_WORD", "YOUR_WORD"];
string[] thirdWords = ["YOUR_WORD", "YOUR_WORD", "YOUR_WORD", "YOUR_WORD", "YOUR_WORD", "YOUR_WORD"];
// NFT トークンの名前とそのシンボルを渡します。
constructor() ERC721 ("SquareNFT", "SQUARE") {
console.log("This is my NFT contract.");
}
// シードを生成する関数を作 成します。
function random(string memory input) internal pure returns (uint256) {
return uint256(keccak256(abi.encodePacked(input)));
}
// 各配列からランダムに単語を選ぶ関数を3つ作成します。
// pickRandomFirstWord関数は、最初の単語を選びます。
function pickRandomFirstWord(uint256 tokenId) public view returns (string memory) {
// pickRandomFirstWord 関数のシードとなる rand を作成します。
uint256 rand = random(string(abi.encodePacked("FIRST_WORD", Strings.toString(tokenId))));
// seed rand をターミナルに出力する。
console.log("rand seed: ", rand);
// firstWords配列の長さを基準に、rand 番目の単語を選びます。
rand = rand % firstWords.length;
// firstWords配列から何番目の単語が選ばれるかターミナルに出力する。
console.log("rand first word: ", rand);
return firstWords[rand];
}
// pickRandomSecondWord関数は、2番目に表示されるの単語を選びます。
function pickRandomSecondWord(uint256 tokenId) public view returns (string memory) {
// pickRandomSecondWord 関数のシードとなる rand を作成します。
uint256 rand = random(string(abi.encodePacked("SECOND_WORD", Strings.toString(tokenId))));
rand = rand % secondWords.length;
return secondWords[rand];
}
// pickRandomThirdWord関数は、3番目に表示されるの単語を選びます。
function pickRandomThirdWord(uint256 tokenId) public view returns (string memory) {
// pickRandomThirdWord 関数の シードとなる rand を作成します。
uint256 rand = random(string(abi.encodePacked("THIRD_WORD", Strings.toString(tokenId))));
rand = rand % thirdWords.length;
return thirdWords[rand];
}
// ユーザーが NFT を取得するために実行する関数です。
function makeAnEpicNFT() public {
// NFT が Mint されるときのカウンターをインクリメントします。
_tokenIds.increment();
// 現在のtokenIdを取得します。tokenIdは1から始まります。
uint256 newItemId = _tokenIds.current();
// 3つの配列からそれぞれ1つの単語をランダムに取り出します。
string memory first = pickRandomFirstWord(newItemId);
string memory second = pickRandomSecondWord(newItemId);
string memory third = pickRandomThirdWord(newItemId);
// 3つの単語を連結して、<text>タグと<svg>タグで閉じます。
string memory finalSvg = string(abi.encodePacked(baseSvg, first, second, third, "</text></svg>"));
// NFTに出力されるテキストをターミナルに出力します。
console.log("\n--------------------");
console.log(finalSvg);
console.log("--------------------\n");
// msg.sender を使って NFT を送信者に Mint します。
_safeMint(msg.sender, newItemId);
// tokenURI は後で設定します。
// 今は、tokenURI の代わりに、"We will set tokenURI later." を設定します。
_setTokenURI(newItemId, "We will set tokenURI later.");
// NFTがいつ誰に作成されたかを確認します。
console.log("An NFT w/ ID %s has been minted to %s", newItemId, msg.sender);
}
}
簡単にコードの内容を説明していきます。
🏷 SVG 形式でデータを表示できるようにする
baseSvg
変数は、SVG形式で単語を表示するために、作成されています。
// MyEpicNFT.sol
string baseSvg = "<svg xmlns='http://www.w3.org/2000/svg' preserveAspectRatio='xMinYMin meet' viewBox='0 0 350 350'><style>.base { fill: white; font-family: serif; font-size: 24px; }</style><rect width='100%' height='100%' fill='black' /><text x='50%' y='50%' class='base' dominant-baseline='middle' text-anchor='middle'>";
makeAnEpicNFT()
関数の中で、3つの単語を連結させて1つのテキストを作成します。
下記では、baseSvg
変数の中身と、"</text></svg>"
で、3つの単語(first
、second
、third
変数に格納された値)を閉じて文字列(string
)として連結しています。
// MyEpicNFT.sol
string memory finalSvg = string(abi.encodePacked(baseSvg, first, second, third, "</text></svg>"));
これで、SVG形式で文字のデータをNFT画像として表示できます。
📝 ラン ダムに組み合わされる単語を設定する
// MyEpicNFT.sol
string[] firstWords = ["YOUR_WORD", "YOUR_WORD", "YOUR_WORD", "YOUR_WORD", "YOUR_WORD", "YOUR_WORD"];
string[] secondWords = ["YOUR_WORD", "YOUR_WORD", "YOUR_WORD", "YOUR_WORD", "YOUR_WORD", "YOUR_WORD"];
string[] thirdWords = ["YOUR_WORD", "YOUR_WORD", "YOUR_WORD", "YOUR_WORD", "YOUR_WORD", "YOUR_WORD"];
YOUR_WORD
に好きな単語を入力してください。
ランダム性を担保するため、配列ごとに15〜20単語程度を格納することをお勧めします。今回の例では、簡単のため6単語を表記しています。
私の配列は下記のようになっています。
string[] firstWords = ["Epic", "Fantastic", "Crude", "Crazy", "Hysterical", "Grand"];
string[] secondWords = ["Meta", "Live", "Pop", "Cute", "Sweet", "Hot"];
string[] thirdWords = ["Kitten", "Puppy", "Monkey", "Bird", "Panda", "Elephant"];
🥴 乱数を生成して、単語をランダムに組み合わせる
下記のコードでは、string[] firstWords
配列からランダムに単語を選ぶ関数を作成しています。
pickRandomFirstWord
関数は、NFT画像に1番目に表示される単語を選びます。
// MyEpicNFT.sol
function pickRandomFirstWord(uint256 tokenId) public view returns (string memory)
{
// pickRandomFirstWord 関数のシードとなる rand を作成します。
uint256 rand = random(string(abi.encodePacked("FIRST_WORD", Strings.toString(tokenId))));
// firstWords配列の長さを基準に、rand 番目の単語を選びます。
rand = rand % firstWords.length;
return firstWords[rand];
}
ここで1つ重要なことを覚えておきましょう。
それは、スマートコントラクトで乱数を生成することは、たいへん難しいということです。
通常のプログラムでは、PCのファンの速度、CPUの温度、インターネット速度など制御が難しい数値を変数に設定し、これらの数値を組み合わせて、「ランダム」な数値を生成するアルゴリズムを作成します。
ですが、ブロックチェーンにおいて、スマートコントラクトは一般に公開されているため、プログラムがどの数値を変数として使用しているのか誰でも確認できてしまいます。
これが、スマートコントラクトで乱数を生成することが難しいと言われている理由です。
今回のプロジェクトでは、下記の方法を用いて、乱数を生成しています。
下記のコードを見ていきましょう。
// MyEpicNFT.sol
uint256 rand = random(string(abi.encodePacked("FIRST_WORD", Strings.toString(tokenId))));
ここでは、文字列FIRST_WORD
と、Strings.toString()
により文字列化されたtokenId
の2つの値をabi.encodePacked
を使用して結合し、rand
に格納しています。
rand
に格納されているのは、次のような値です。
96777463446932378109744360884080025980584389114515208476196941633474201541706
rand
は、乱数を生成するための「種」です。ですので、値そのものに意味はありません。
次に、次のコードを見ていきましょう。
// MyEpicNFT.sol
rand = rand % firstWords.length;
return firstWords[rand];
ここでは、firstWords
配列の長さを基準に、rand
番目の単語を選んでいます。
%
は、整数の割り算における余りを返します。
下記のような例では、%
により割り算の余りが整数で返されます。
1 % 2 = 1 // 1が余り
2 % 2 = 0 // 0が余り
rand = rand % firstWords.length
では、0
からfirstWords.length - 1
の間の任意の値をrand
に格納しています。
これにより、firstWords
配列からランダムに値を選べます。
-
私の
firstWords
配列には6つの単語が格納されています。 -
Solidityでは、配列に最初に格納されている値を
0
番目ととらえます。 -
したがって、私の例では、
rand % firstWords.length
によって、0
から5
までの値が1つ返されます。
⚠️: 注意
上記のアルゴリズムは、完全なランダム性を持ちません。
今回乱数を使用するのは、あくまで「文字列の生成」のためなので、強固なランダム性は必要ではありません。
例えば、「ランダムにユーザーを選んで、ETH を送金する」ようなプログラムを実装する際は、さらに強固な乱数生成のアルゴリズムを実装することになります。
今回のプロジェクトでは、その必要がないので、上記のアルゴリズムを採用します。
Solidityは、インプットが同じであれば必ず同じ結果が出力されるように設計されているため、公式な乱数生成の処理をサポートするライブラリを提供していません。
Solidityにおける乱数生成の方法に興味があれば、Chainlink(英語) のドキュメントを参照してみましょう。
👩🔬 自動テストを作成してみよう
スマートコントラクトに新たな機能が追加されたので、それに伴い自動テストを作成してみましょう。
HardHatには、test
ディレクトリ内に格納されたテストコードをnpx hardhat test
コマンドを実行することで、自動的にテストを走らせてくれる機能があります。今回作成するテストは、スマートコントラクト内に定義した各関数を1つずつテストする**ユニットテスト(unit test)**と呼ばれるものです。実際にスマートコントラクトをデプロイして機能を使う前段階として、その機能が期待する動作を行うか確認することができるテストとなります。
Hardhatの自動テストを利用して、スマートコントラクトの機能をテストしてみましょう。
まずは、packages/contract/test
ディレクトリ内にMyEpicNFT.js
というファイルを作成します。
packages/
└──contract/
└── test/
+ └── MyEpicNFT.js
続いて、作成したMyEpicNFT.js
に以下のコードを書き込みます。
const { loadFixture } = require("@nomicfoundation/hardhat-network-helpers");
const { ethers } = require("hardhat");
const { expect } = require("chai");
describe("MyEpicNFT", function () {
// 各テストの前に呼び出す関数です。テストで使用する変数やコントラクトのデプロイを行います。
async function deployMyEpicNFTFixture() {
// テストアカウントを取得します。
const [owner] = await ethers.getSigners();
// コントラクト内で使用する単語の配列を定義します。
const firstWords = [
"Epic",
"Fantastic",
"Crude",
"Crazy",
"Hysterical",
"Grand",
];
const secondWords = ["Meta", "Live", "Pop", "Cute", "Sweet", "Hot"];
const thirdWords = [
"Kitten",
"Puppy",
"Monkey",
"Bird",
"Panda",
"Elephant",
];
// コントラクトのインスタンスを生成し、デプロイを行います。
const MyEpicNFTFactory = await ethers.getContractFactory("MyEpicNFT");
const MyEpicNFT = await MyEpicNFTFactory.deploy();
return { MyEpicNFT, owner, firstWords, secondWords, thirdWords };
}
describe("pickRandomFirstWord", function () {
it("should get strings in firstWords", async function () {
// テストの準備を行います。
const { MyEpicNFT, firstWords } = await loadFixture(
deployMyEpicNFTFixture
);
// テストを行う関数を呼び出し、結果を確認します。
expect(firstWords).to.include(await MyEpicNFT.pickRandomFirstWord(0));
});
});
describe("pickRandomSecondWord", function () {
it("should get strings in secondWords", async function () {
const { MyEpicNFT, secondWords } = await loadFixture(
deployMyEpicNFTFixture
);
expect(secondWords).to.include(await MyEpicNFT.pickRandomSecondWord(0));
});
});
describe("pickRandomThirdWord", function () {
it("should get strings in thirdWords", async function () {
const { MyEpicNFT, thirdWords } = await loadFixture(
deployMyEpicNFTFixture
);
expect(thirdWords).to.include(await MyEpicNFT.pickRandomThirdWord(0));
});
});
});
コードの内容を簡単にみていきましょう。
最初に定義したのは、各テストの前に実行するdeployMyEpicNFTFixture
関数です。実際に機能をテストしたいコントラクト内の関数を呼び出すための、準備を行う関数となります。