100時間後に死ぬNFT のコード解説(1) Solidity
先日公開した「100時間後に死ぬNFT」のコード解説です。長くなったので2回に分割します。今回はSolidity編です。
時間経過による変化
このNFTは時間経過でライフ(life)や年齢(age)といったパラメータが変化し、それによって見た目も変わります。 年齢によって大きくなりますし、ライフが減少すると動きが遅くなります。ライフが0になると死んでしまい、墓石が表示されます。
実は、ライフや年齢はストレージ変数に保存しているわけではありません。 ストレージ変数を更新するにはトランザクションが必要になりますが、時間経過のたびにトランザクションを発行するのは大変です。 そこで、tokenURIを呼び出すたびに、その時刻におけるライフや年齢の値を計算しています。
ライフや年齢を計算するために必要なのが、生まれた時刻(birthTime)と、最後にエサをやった時刻(lastFeedTime)です。
claim関数とfeed関数が呼び出されると、これらの変数をストレージに保存しています。現在時刻は block.timestamp
を使います。
function claim(string memory name) external { // (一部抜粋) Creature storage c = creatures[_tokenId]; c.birthTime = block.timestamp; // 現在時刻を保存 c.lastFeedTime = block.timestamp; // 〃 } function feed(uint256 tokenId) external { // (一部抜粋) c.lastFeedTime = block.timestamp; // 現在時刻を保存 }
ライフを計算するには、最後にエサをやった時刻が、今から何時間前かを計算して、ライフ最大値(100)から引きます。
例えば、3時間前にエサをやったなら、ライフは97です。
function getLife(Creature storage c) internal view returns (uint256) { int256 life = int256(MAX_LIFE) - int256((block.timestamp - c.lastFeedTime) / ONE_HOUR); return life <= 0 ? 0 : uint256(life); }
年齢を計算するには、まず、(1)生きている場合は、現在時刻と生まれた時刻の差から計算します。
(2)死んでしまった場合は、最後にエサをやった時刻と生まれた時刻の差に100を足したものが、死亡時の年齢になります。
(最後にエサをやってから100時間後に死ぬため。)
function getAge(Creature storage c) internal view returns (uint256) { if (isAlive(c)) { return (block.timestamp - c.birthTime) / ONE_HOUR; } else { return (c.lastFeedTime - c.birthTime) / ONE_HOUR + MAX_LIFE; // age at death } }
死後に経過した時間を求める関数も書きましたが、これは結局使いませんでした。
死後の時間経過で墓石が劣化する、という演出を考えていたのですが、めんどくさくなってやめました(笑)
function getHoursAfterDeath(Creature storage c) internal view returns (uint256) { return (block.timestamp - c.lastFeedTime) / ONE_HOUR - MAX_LIFE; }
Solidityの時間単位
上のコードで出てくる ONE_HOURは、
uint256 private constant ONE_HOUR = 1 hours;
として定義しています。
Solidityの時間単位として、seconds, minutes, hours, days, weeksなどがあります。 基本の単位は秒なので、以下のようになります。
1 seconds == 1 1 minutes == 60 1 hours == 60 * 60 1 days == 60 * 60 * 24 1 weeks == 60 * 60 * 24 * 7
ONE_HOURを定数として定義しているのは、時間経過の速度を簡単に変更できるようにするためです。
開発中や、テストネットでの動作テストのときは、結果を確認するために100時間も待ってられないので、
ONE_HOUR = 1 hours / 100
としていました。こうすると100倍速でライフが減っていき、1時間で死ぬことになります。
時間経過のテスト
Hardhatの使い方(4)テスト でも書きましたが、
Hardhatのローカルネットワークを使っているときは evm_increaseTime
コマンドで、強制的に時間経過させることができます。
// テストスクリプト(抜粋) it("100時間後に死ぬ", async function () { // 99時間すすめる await network.provider.send("evm_increaseTime", [60 * 60 * 99]); await network.provider.send("evm_mine"); // マイニング let params = await contract.getParams(tokenID); expect(params.life).equal(1); expect(params.age).equal(99); expect(params.isAlive).equal(true); // まだ生きている // さらに1時間すすめる await network.provider.send("evm_increaseTime", [60 * 60]); await network.provider.send("evm_mine"); params = await contract.getParams(tokenID); expect(params.life).equal(0); expect(params.age).equal(100); expect(params.isAlive).equal(false); // 死んでいる });
struct (構造体)
今回始めてSolidityのstructを使ってみました。
ペットごとに、claimした時刻(birthTime)、最後にエサをやった時刻(lastFeedTime)、名前(name) の3つを保存していますが、
それらをまとめてCreature
として定義しています。
struct Creature { uint256 birthTime; uint256 lastFeedTime; string name; }
Creatureについての関数は CreatureLibというライブラリにまとめています。 getLife, isAliveなどの関数をlibraryで定義しておいて、 usingを使うことで、クラスメソッドのように扱えます。(C#の拡張メソッドのようなもの)
// libraryで関数を定義 library CreatureLib { function getLife(Creature storage c) view returns (uint256) {...} function isAlive(Creature storage c) view returns (bool) {...} }
// contract内で使用 using CreatureLib for Creature; ... Creature storage c = creatures[tokenId]; uint life = c.getLife(); // CreatureLib.getLife(c) と同じ bool alive = c.isAlive(); // CreatureLib.isAlive(c) と同じ
Stack too deep
SVGを生成する関数で、abi.encodePackedの引数が多すぎたようで、Stack too deepエラーが出てしまいました。 引数は16個までOKのはずですが、それ以下でもStack too deepが出てしまうこともあるようです。 (そもそもstackやopcodeをあまり理解していないので、この辺はよくわかっていません😓)
とりあえずabi.encodePackedを複数に分けることで対処しています。
return string(abi.encodePacked( abi.encodePacked( '<svg xmlns="http://www.w3.org/2000/svg" ...', size.toString(), unicode'">🐊<animateMotion ...', moveTime.toString(), 's" repeatCount="indefinite"/>...', name ), abi.encodePacked( '</text><text y="-120" font-size="20">', age.toString(), ' hours old</text>...', lifeGaugeColor, '" stroke="#000" d="M-160 120v20h', lifeGaugeWidth.toString(), 'v-20z"/><text y="160" font-size="20">LIFE ', life.toString(), "</text></svg>" ) ));
Stack too Deepを攻略する - LayerX Research
おわりに
次回はコード解説(2)のSVG編です。