Now and Nawoo

NFTの制作記録、技術メモ → C#, Solidity, Blockchain, Bitcoin, Ethereum, NFT

100時間後に死ぬNFT のコード解説(1) Solidity

先日公開した「100時間後に死ぬNFT」のコード解説です。長くなったので2回に分割します。今回はSolidity編です。

100時間後に死ぬNFTの紹介記事
ソースコードはこちら

時間経過による変化

このNFTは時間経過でライフ(life)や年齢(age)といったパラメータが変化し、それによって見た目も変わります。 年齢によって大きくなりますし、ライフが減少すると動きが遅くなります。ライフが0になると死んでしまい、墓石が表示されます。

f:id:nawoo5:20211123140341p:plain:w600

実は、ライフや年齢はストレージ変数に保存しているわけではありません。 ストレージ変数を更新するにはトランザクションが必要になりますが、時間経過のたびにトランザクションを発行するのは大変です。 そこで、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

Time Units - Solidity

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編です。