💧

【Sui】marimoNFTをSuiで実装するパターン3つ

2024/07/05に公開

marimoとは

定期的に水を換えてmarimoを育てるという、とてもシンプルなものですが、当時(2年くらい前)NFTへのオンボーディングになるような素晴らしいプロジェクトだった記憶があります(あとかわいい)

SuiとMoveを学習中の身として、何か具体的な開発題材があるといいなーと思ってたところ、marimoを思い出し、簡易版を実装してみたくなりました

Suiでの実装仕様

今回の簡易版の実装では以下のような要件にしたいと思います

  • ペットNFT
    • ペットNFTは常に独立してオンチェーンに存在する
    • SuiVisionで常に画像やFieldの値を確認することができる
    • ペットNFTの所有権は、発行者(以下オーナー)が持っている
    • 所有権が一時的に離れたとしても、オーナーによっていつでも所有権を戻すことができる
  • ペットに対してできること
    • オーナーはペットを撫でることができる(A)
    • 撫でるとcuteポイントがupする
    • 公園にペットを放つと、オーナー以外も撫でることができる(B)
    • 公園から去ると、(A)の状態に戻る

実装パターン1: Wrapped Objects

最初に思いついたのが Wrapped Objects を使う方法でした。いろんなMoveコントラクトを読むと頻繁に使われていますし、これがストレートなアプローチだと思いました。 Shared Object(以下SO)を作成し、SOでPetNFTをラップすれば、(B)の要件が満たせると考えました

コードを見てみましょう(一部抜粋、全コードはこちら

module onigiri::pet_wo {
    // === Structs ===
    public struct PET_WO has drop {}
    public struct PetNFT has key, store {
        id: UID,
        cute: u64,
    }
    public struct Park has key, store {
        id: UID,
        pet: Option<PetNFT>,
    }
    public struct PetOwnerCap has key, store {
        id: UID,
        pet_id: ID,
    }

    // === Initialization ===
    fun init(otw: PET_WO,  ctx: &mut TxContext) {
        // Create a park as a shared object
        transfer::public_share_object(Park {
            id: object::new(ctx),
            pet: option::none(),
        });
    }

    entry public fun create_pet(ctx: &mut TxContext) {
        transfer::transfer(PetNFT {
            id: object::new(ctx),
            cute: 0,
        }, ctx.sender());
    }

    public fun naderu(pet: &mut PetNFT) {
        pet.cute = pet.cute + 1;
    }

    entry public fun pet_to_park(pet: PetNFT, park: &mut Park, ctx: &mut TxContext) {
        let pet_id = pet.id.uid_to_inner();
        option::fill(&mut park.pet, pet);
        transfer::transfer(PetOwnerCap {
            id: object::new(ctx),
            pet_id,
        }, ctx.sender());
    }

    public fun naderu_at_park(park: &mut Park) {
        assert!(option::is_some(&park.pet), 0);
        let pet = option::borrow_mut(&mut park.pet);
        naderu(pet);
    }

    entry public fun pet_from_park(park: &mut Park, pet_owner_cap: PetOwnerCap, ctx: &mut TxContext) {
        assert!(option::is_some(&park.pet), 0);
        let pet = option::extract(&mut park.pet);
        let PetOwnerCap { id, pet_id } = pet_owner_cap;
        assert!(pet.id.uid_to_inner() == pet_id, 1);
        id.delete();
        transfer::transfer(pet, ctx.sender());

    }
}

Park(SO)を作成し、pet_to_park関数を呼ぶことで、ParkでPetNFTをラップすることができます。ラップした上でnaderu_at_park関数を呼ぶことで、pet.cuteの値が変わることに成功したのですが、、、

肝心のNFTがフロントで表示されなくなってしまいました😇

ラップされたオブジェクトの挙動についてはdocsにも記載があります

There are some interesting consequences of wrapping a Sui object into another. When an object is wrapped, the object no longer exists independently on-chain. You can no longer look up the object by its ID. The object becomes part of the data of the object that wraps it. Most importantly, you can no longer pass the wrapped object as an argument in any way in Sui Move calls. The only access point is through the wrapping object.

Suiのオブジェクトを別のオブジェクトにラップすることで、いくつかの興味深い結果が得られる。オブジェクトがラップされると、そのオブジェクトはもはやチェーン上で独立して存在しなくなります。オブジェクトをIDで検索することもできなくなります。オブジェクトはそれをラップしたオブジェクトのデータの一部になります。最も重要なことは、ラップされたオブジェクトをSui Move呼び出しの引数として渡すことができなくなったことです。唯一のアクセスポイントは、ラップされたオブジェクトを経由することです。

ラップされたPetNFT自体は、オンチェーンで独立して存在しなくなってしまうんですね。なんとなく動作はしたのですが、これでは要件を満たしていないと判断しました

実装パターン2: Dynamic Object Fields

次に思い浮かんだのが、 Dynamic Object Fields(以下DOF)です。 Wrapped ObjectsによってNFTが独立して存在できなくなるのであれば、SOに対してPetNFTをDOFとして追加すれば解決できるのではないか?と考えました

コードを見てみましょう(一部抜粋、全コードはこちら

module onigiri::pet_dof {
    // === Structs ===
    public struct PET_DOF has drop {}
    public struct PetNFT has key, store {
        id: UID,
        cute: u64,
    }
    public struct Park has key, store {
        id: UID,
    }
    public struct PetOwnerCap has key, store {
        id: UID,
        pet_id: ID,
    }

    // === Initialization ===
    fun init(otw: PET_DOF,  ctx: &mut TxContext) {
        // Create a park as a shared object
        transfer::public_share_object(Park {
            id: object::new(ctx),
        });
    }

    entry public fun create_pet(ctx: &mut TxContext) {
        transfer::transfer(PetNFT {
            id: object::new(ctx),
            cute: 0,
        }, ctx.sender());
    }

    public fun naderu(pet: &mut PetNFT) {
        pet.cute = pet.cute + 1;
    }

    entry public fun pet_to_park(pet: PetNFT, park: &mut Park, ctx: &mut TxContext) {
        let pet_id = pet.id.uid_to_inner();
        dof::add(&mut park.id, b"pet", pet); 
        transfer::transfer(PetOwnerCap {
            id: object::new(ctx),
            pet_id,
        }, ctx.sender());
    }

    public fun naderu_at_park(park: &mut Park) {
        let pet = dof::borrow_mut(&mut park.id, b"pet");
        naderu(pet);
    }

    entry public fun pet_from_park(park: &mut Park, pet_owner_cap: PetOwnerCap, ctx: &mut TxContext) {
        let pet: PetNFT = dof::remove(&mut park.id, b"pet");
        let PetOwnerCap { id, pet_id } = pet_owner_cap;
        assert!(pet.id.uid_to_inner() == pet_id, 1);
        id.delete();
        transfer::transfer(pet, ctx.sender());
    }
}

DOFはあまり馴染みがなかったのですが、PetNFTがDynamic Fieldsとして追加されているのがわかります

Wrapped Objectとは違い、PetNFTは独自でオンチェーンにも存在しています

これは要件を満たしており、そこまで悪くない実装だと思いました

欠点としては、DOF自体の利用です

DOFはkeyの取り扱いに慎重にならなければいけませんし、RPCコールが増えるというデメリットがあります

実装パターン3: Transfer to Object

最後のパターンが Transfer to Object(以下TO)です。普段はアカウントにオブジェトを送ることはあっても、オブジェクトにオブジェクトを送ることはないため、これも馴染みのないものでした

コードを見てみましょう(一部抜粋、全コードはこちら

module onigiri::pet_to {
    // === Structs ===
    public struct PET_TO has drop {}
    public struct PetNFT has key, store {
        id: UID,
        cute: u64,
    }
    public struct Park has key, store {
        id: UID,
    }
    public struct PetOwnerCap has key, store {
        id: UID,
        pet_id: ID,
    }

    // === Initialization ===
    fun init(otw: PET_TO,  ctx: &mut TxContext) {
        // Create a park as a shared object
        transfer::public_share_object(Park {
            id: object::new(ctx),
        });
    }

    entry public fun create_pet(ctx: &mut TxContext) {
        transfer::transfer(PetNFT {
            id: object::new(ctx),
            cute: 0,
        }, ctx.sender());
    }

    public fun naderu(pet: &mut PetNFT) {
        pet.cute = pet.cute + 1;
    }

    entry public fun pet_to_park(pet: PetNFT, park: &Park, ctx: &mut TxContext) {
        let pet_id = pet.id.uid_to_inner();
        transfer::public_transfer(pet, park.id.uid_to_address());
        transfer::transfer(PetOwnerCap {
            id: object::new(ctx),
            pet_id,
        }, ctx.sender());
    }

    public fun naderu_at_park(park: &mut Park, pet_to_receive: Receiving<PetNFT>) {
        let mut pet = transfer::receive(&mut park.id, pet_to_receive);
        naderu(&mut pet);
        transfer::transfer(pet, park.id.uid_to_address());
    }

    entry public fun pet_from_park(park: &mut Park, pet_owner_cap: PetOwnerCap, pet_to_receive: Receiving<PetNFT>, ctx: &mut TxContext) {
        let pet = transfer::receive(&mut park.id, pet_to_receive);
        let PetOwnerCap { id, pet_id } = pet_owner_cap;
        assert!(pet.id.uid_to_inner() == pet_id, 1);
        id.delete();
        transfer::transfer(pet, ctx.sender());
    }
}

ParkにPetNFTを送信後、ParkのオブジェクトIDで検索すると、ObjectとAccountの2つが検索で表示されるようになります。これはユニークで面白いですよね

Objectがアカウントとして振る舞っていることがわかります

TOによる実装で良いなと思ったのが、 pet_from_park関数です。 pet_to_receive: Receiving<PetNFT>として関数の引数にPetNFTを指定することができるので、インターフェースが明確になりますし、複雑な追加要件が出てきたときにも柔軟に対応ができると思いました

まとめ

いろいろと苦戦しながらも3つの実装パターンに辿り着きました。結果的に採用できそうなのは Dynamic Object FieldsTransfer to Objectを使うパターンだと思います

おそらくこれら以外のアプローチも存在します。1つはKioskです。Kioskも試してみたいなと思ったのですがまだキャッチアップできていないので、これは宿題とします

今回はシンプルな要件ですが、例えば「公園に複数のペットを放てるようにする」といった要件に対応したいとき、紹介したパターンでは限界があるかもしれないなと思いました。ここもKioskであれば柔軟に対応できるかもしれません。こちらも今後の宿題とします

最後に、Ethereumの場合 onlyOwner をつけるかつけないかで簡単に今回の要件を満たせると思うのですが、オブジェクトベースのSuiでは簡単には行かなく、EthereumとSuiの比較にもなり、良い題材となりました

Discussion