🔖

FlowチェーンにNFTガチャを実装してデプロイする

2023/03/15に公開

Flowチェーンのテストネットにスマートコントラクトをデプロイしてwebフロントから接続してみたのでその備忘録です。作成するのは抽選アイテムをもったスマートコントラクトでランダムに抽選してアイテムをmintします。NFTのガチャのイメージです。

Flow、Cadenceについての詳細な説明は本記事では行いません。Cadenceについて詳しく知りたい方は公式ドキュメントを参照していただくかわたしが執筆したzenn本がありますのでよければご参照ください。

スマートコントラクトの作成

まずはFlow CLIで雛形を作成します。

flow setup gacha-nft

ガチャインターフェイスを作成

ガチャコントラクトを作成するための処理をインターフェイスを作成することで抽象化します。インターフェイスに実装する機能は以下の通りです。

Item

ガチャのアイテム抽選には排出アイテムそれぞれに一定の重みを設定し、抽選時に重み付け抽選ができるようにします。各アイテムはidを割り振りkeyにid, valueにアイテムの重みとした辞書型の変数をコントラクトに持たせます。

Gacha.cdc
pub contract interface Gacha {
    /// 抽選mintするための重み設定 Weight setting for lottery minting
    pub struct interface HasWeight {
      /// 重み weight
      pub let weight: UInt64
    }

    /// ガチャコントラクトからmintされるアイテム
    /// item minted by this gacha contract
    pub struct Item: HasWeight {
      pub let weight: UInt64
    }

    /// key: item_id value: item
    pub let ids: {UInt64: AnyStruct{HasWeight}}

Collection

ユーザーが保持するCollectionリソースの定義をします。ユーザーがどのアイテムをいくつ持っているかの情報と数量の増減、数量の取得関数の定義をします。

Gacha.cdc
    pub resource interface IncreceAmount {
      pub fun increceAmount(id: UInt64, amount: UInt32)
    }

    pub resource interface DecreceAmount {
      pub fun decreseAmount(id: UInt64, amount: UInt32)
    }

    pub resource interface GetAmounts {
      pub fun getAmount(id: UInt64): UInt32
      pub fun getAmounts(): {UInt64:UInt32}
    }

    pub resource Collection: IncreceAmount, DecreceAmount, GetAmounts {
      /// key: item_id value: amount
      pub var ownedAmounts: {UInt64:UInt32}

      /// increce the item amount
      pub fun increceAmount(id: UInt64, amount: UInt32)

      /// decrece the item amount.
      /// must have more than specifyed amount.
      pub fun decreseAmount(id: UInt64, amount: UInt32) {
        pre {
          self.ownedAmounts[id] == nil: "Not have token!!"
          self.ownedAmounts[id]! - amount < 0: "The amount you do not have is specified!"
        }
      }

      /// get specified id item amount 
      pub fun getAmount(id: UInt64): UInt32

      /// get all item id and amount
      pub fun getAmounts(): {UInt64:UInt32}
    }

ファイルの全体は以下のような感じです。

Gacha.cdc
/// NFTコレクションにガチャの機能を組み込むためのインターフェイス
/// このインターフェイスが実装されたコントラクトが1つのガチャ筐体を表現する
/// Interface to incorporate gacha functionality into NFT collectionss.
/// The contract in which this interface is implemented represents a single mess enclosure.
pub contract interface Gacha {
    /// increce amount event
    pub event Increce(id: UInt64, beforeAmount: UInt32, afterAmount: UInt32)
    /// decrece amount event
    pub event Decrece(id: UInt64, beforeAmount: UInt32, afterAmount: UInt32)

    /// 抽選mintするための重み設定 Weight setting for lottery minting
    pub struct interface HasWeight {
      /// 重み weight
      pub let weight: UInt64
    }

    /// ガチャコントラクトからmintされるアイテム
    /// item minted by this gacha contract
    pub struct Item: HasWeight {
      pub let weight: UInt64
    }

    /// key: item_id value: item
    pub let ids: {UInt64: AnyStruct{HasWeight}}

    pub resource interface IncreceAmount {
      pub fun increceAmount(id: UInt64, amount: UInt32)
    }

    pub resource interface DecreceAmount {
      pub fun decreseAmount(id: UInt64, amount: UInt32)
    }

    pub resource interface GetAmounts {
      pub fun getAmount(id: UInt64): UInt32
      pub fun getAmounts(): {UInt64:UInt32}
    }

    pub resource Collection: IncreceAmount, DecreceAmount, GetAmounts {
      /// key: item_id value: amount
      pub var ownedAmounts: {UInt64:UInt32}

      /// increce the item amount
      pub fun increceAmount(id: UInt64, amount: UInt32)

      /// decrece the item amount.
      /// must have more than specifyed amount.
      pub fun decreseAmount(id: UInt64, amount: UInt32) {
        pre {
          self.ownedAmounts[id] == nil: "Not have token!!"
          self.ownedAmounts[id]! - amount < 0: "The amount you do not have is specified!"
        }
      }

      /// get specified id item amount 
      pub fun getAmount(id: UInt64): UInt32

      /// get all item id and amount
      pub fun getAmounts(): {UInt64:UInt32}
    }
}

NonFungibleTokenコントラクト

FlowでNFTを実装する場合、既に用意されているコントラクトをいくつか使用することができます。

https://github.com/onflow/flow-nft/tree/master/contracts

今回はこれらのコントラクトからNonFungibleToken, MetadataViews, FungibleTokenをコピーして配置します。

.
├── contracts
│   ├── Gacha.cdc
│   └── lib
│       ├── MetadataViews.cdc
│       ├── NonFungibleToken.cdc
│       └── utility
│           └── FungibleToken.cdc

ガチャコントラクトの実装

作成したインターフェイスをコントラクトに実装します。

アイテムを抽選するためのidsフィールド

以下のようなidとItemの構造体の対応表をフィールドで保持させ、抽選に利用します。

GachaNFT.cdc
    /// NFTとして発行するトークン情報
    pub struct Item: Gacha.HasWeight {
      pub let id: UInt64
      pub let name: String
      pub let description: String
      pub let thumbnail: String
      pub let weight: UInt64

      init(
        id: UInt64,
        name: String,
        description: String,
        thumbnail: String,
        rarity: String,
        weight: UInt64
      ) {
        self.id = id
        self.name = name
        self.description = description
        self.thumbnail = thumbnail
        self.weight = weight
      }
    }

    /// key: token_kind_id value: token_info
    pub let ids: {UInt64: AnyStruct{Gacha.HasWeight}}

NFTリソース

1アイテム情報を表すNFTリソースの定義。idやサムネイル画像などの情報を持たせる。

GachaNFT.cdc
    // NonFungibleToken override
    pub resource NFT: NonFungibleToken.INFT, MetadataViews.Resolver {
      // NonfungibleToken.INFT override token kind id(not unique)
      pub let id: UInt64

      /// metadata
      pub let name: String
      pub let description: String
      pub let thumbnail: String
      access(self) let royalties: [MetadataViews.Royalty]
      access(self) let metadata: {String: AnyStruct}

      init(
        id: UInt64,
        name: String,
        description: String,
        thumbnail: String,
        royalties: [MetadataViews.Royalty],
        metadata: {String: AnyStruct}
      ) {
        self.id = id
        self.name = name
        self.description = description
        self.thumbnail = thumbnail
        self.royalties = royalties
        self.metadata = metadata
      })

コレクションリソース

このガチャNFTのコレクションリソース。このコントラクトのNFTトークンをmintするにはこのコレクションリソースをユーザーのアカウントストレージに保存します。フィールドには所持しているNFT情報とNFTの所持数などを保持します。

GachaNFT.cdc
    // NonFungibleToken override
    pub resource Collection: 
      GachaNFTCollectionPublic,
      NonFungibleToken.Provider,
      NonFungibleToken.Receiver,  
      NonFungibleToken.CollectionPublic, 
      MetadataViews.ResolverCollection,
      Gacha.IncreceAmount,
      Gacha.DecreceAmount,
      Gacha.GetAmounts
    {
      // NonFungibleToken.Collection override
      pub var ownedNFTs: @{UInt64: NonFungibleToken.NFT}

      pub var ownedAmounts: {UInt64: UInt32}

      init() {
        self.ownedNFTs <- {}
        self.ownedAmounts = {}
      })

NFTMinter

NFTをmintするためのminterリソースを以下のように定義します。

GachaNFT.cdc
    pub resource NFTMinter {
    
      pub fun mint(
        recipient: &{NonFungibleToken.CollectionPublic},
        royalties: [MetadataViews.Royalty],
        item: Item,
      ) {
          let metadata: {String: AnyStruct} = {}
          let currentBlock = getCurrentBlock()
          metadata["mintedBlock"] = currentBlock.height
          metadata["mintedTime"] = currentBlock.timestamp
          metadata["minter"] = recipient.owner!.address

          // create a new NFT
          var newNFT <- create NFT(
              id: item.id,
              name: item.name,
              description: item.description,
              thumbnail: item.thumbnail,
              royalties: royalties,
              metadata: metadata,
          )

          // deposit it in the recipient's account using their reference
          recipient.deposit(token: <-newNFT)

          GachaNFT.totalSupply = GachaNFT.totalSupply + 1
      }
    }

コントラクトの初期化

コントラクトの初期化処理は以下のように定義します。本当はガチャの排出アイテムの初期化にはコントラクトデプロイ時の引数などで渡すようにしたかったのですが一旦初期化処理にハードコーディングしています。

GachaNFT.cdc
    init() {
      self.totalSupply = 0
      
      // パスの初期化
      self.CollectionStoragePath = StoragePath(identifier: "GachaNFTCollection") ?? panic("can not specify storage path.")
      self.CollectionPublicPath = PublicPath(identifier: "GachaNFTCollection") ?? panic("can not specify public path.")
      self.MinterStoragePath = StoragePath(identifier: "GachaNFTMinter") ?? panic("can not specify storage path.")
      self.GachaPublicPath = PublicPath(identifier: "GachaPublic") ?? panic("can not specify public path.")

      // このガチャの排出アイテム初期化
      self.ids = {
        1: Item(
          id: 1, name: "Item1", description: "Normal item.", thumbnail: "QmSzzQjaQSsUgYpxXxtF1mRgUzFYKh5HZQRi2RehNs8ZhH", rarity: "N", weight: 60
        ),
        2: Item(
          id: 2, name: "Item2", description: "Rea item.", thumbnail: "QmeHqCZ2M3FJa1J91Rd8arhKj5UBAmbs4i3mHxs6QVz6xS", rarity: "R", weight: 30
        ),
        3: Item(
          id: 3, name: "Item3", description: "Super Rea item.", thumbnail: "QmQCrYirym911cBSygYX84sWmUmirtRqpXiZFVr67s5pm7", rarity: "SR", weight: 10
        )
      }
    }

コントラクトの最終的な実装はこちらです。

コントラクトの最終的な実装
GachaNFT.cdc
import NonFungibleToken from "./lib/NonFungibleToken.cdc"
import MetadataViews from "./lib/MetadataViews.cdc"
import Gacha from "./Gacha.cdc"

pub contract GachaNFT: NonFungibleToken, Gacha {
    // NonFungibleToken override
    pub var totalSupply: UInt64

    /// event
    // NonFungibleToken override
    pub event ContractInitialized()
    // NonFungibleToken override
    pub event Withdraw(id: UInt64, from: Address?)
    // NonFungibleToken override
    pub event Deposit(id: UInt64, to: Address?)

    pub event Increce(id: UInt64, beforeAmount: UInt32, afterAmount: UInt32)
    pub event Decrece(id: UInt64, beforeAmount: UInt32, afterAmount: UInt32)

    /// path
    pub let CollectionStoragePath: StoragePath
    pub let CollectionPublicPath: PublicPath
    pub let MinterStoragePath: StoragePath
    pub let GachaPublicPath: PublicPath

    /// NFTとして発行するトークン情報
    pub struct Item: Gacha.HasWeight {
      pub let id: UInt64
      pub let name: String
      pub let description: String
      pub let thumbnail: String
      pub let weight: UInt64

      init(
        id: UInt64,
        name: String,
        description: String,
        thumbnail: String,
        rarity: String,
        weight: UInt64
      ) {
        self.id = id
        self.name = name
        self.description = description
        self.thumbnail = thumbnail
        self.weight = weight
      }
    }

    /// key: token_kind_id value: token_info
    pub let ids: {UInt64: AnyStruct{Gacha.HasWeight}}

    // NonFungibleToken override
    pub fun createEmptyCollection(): @NonFungibleToken.Collection {
      return <- create Collection()
    }

    // NonFungibleToken override
    pub resource NFT: NonFungibleToken.INFT, MetadataViews.Resolver {
      // NonfungibleToken.INFT override token kind id(not unique)
      pub let id: UInt64

      /// metadata
      pub let name: String
      pub let description: String
      pub let thumbnail: String
      access(self) let royalties: [MetadataViews.Royalty]
      access(self) let metadata: {String: AnyStruct}

      init(
        id: UInt64,
        name: String,
        description: String,
        thumbnail: String,
        royalties: [MetadataViews.Royalty],
        metadata: {String: AnyStruct}
      ) {
        self.id = id
        self.name = name
        self.description = description
        self.thumbnail = thumbnail
        self.royalties = royalties
        self.metadata = metadata
      }
    
      // MetadaViews.Resolver override 
      pub fun getViews(): [Type] {
        return [
            Type<MetadataViews.Display>(),
            Type<MetadataViews.Royalties>(),
            Type<MetadataViews.Editions>(),
            Type<MetadataViews.ExternalURL>(),
            Type<MetadataViews.NFTCollectionData>(),
            Type<MetadataViews.NFTCollectionDisplay>(),
            Type<MetadataViews.Serial>(),
            Type<MetadataViews.Traits>()
        ]
      }

      // MetadaViews.Resolver override 
      pub fun resolveView(_ view: Type): AnyStruct? {
        switch view {
          // basic view thumbnail is http url or ipfs path
          case Type<MetadataViews.Display>():
            return MetadataViews.Display(
                name: self.name,
                description: self.description,
                thumbnail: MetadataViews.IPFSFile(
                    cid: self.thumbnail,
                    path: nil
                )
            )
          // 複数のオブジェクトを発行するコレクション
          case Type<MetadataViews.Editions>():
            let editionInfo = MetadataViews.Edition(
              name: "Example NFT Edition", // ex) Play, Series...
              number: self.id, // #20/100 の20の部分
              max: nil // #20/100の100の部分。無制限の場合はnil
            )
            let editionList: [MetadataViews.Edition] = [editionInfo]
            return MetadataViews.Editions(
                editionList
            )
          // プロジェクト内の他のNFTの間で一意となるSerial number
          case Type<MetadataViews.Serial>():
            return MetadataViews.Serial(self.id)
          // ロイヤリティー情報
          case Type<MetadataViews.Royalties>():
            return MetadataViews.Royalties(self.royalties)
          // 外部URL
          case Type<MetadataViews.ExternalURL>():
            return MetadataViews.ExternalURL("https://example.com/".concat(self.id.toString()))
          // NFTコレクション情報
          case Type<MetadataViews.NFTCollectionData>():
            return MetadataViews.NFTCollectionData(
              storagePath: GachaNFT.CollectionStoragePath, // NFTのストレージパス
              publicPath: GachaNFT.CollectionPublicPath, // NFTの参照publicパス
              providerPath: /private/GachaNFTCollection, // NFTの参照privateパス
              publicCollection: Type<&GachaNFT.Collection{GachaNFT.GachaNFTCollectionPublic}>(), // publicなNFTコレクション型.通常、以下のpublicLinkedTypeと一致するが古いコレクションの下位互換のためにある
              publicLinkedType: Type<&GachaNFT.Collection{GachaNFT.GachaNFTCollectionPublic,NonFungibleToken.CollectionPublic,NonFungibleToken.Receiver,MetadataViews.ResolverCollection}>(),
              // 前述のprivateパスにある参照の型
              providerLinkedType: Type<&GachaNFT.Collection{GachaNFT.GachaNFTCollectionPublic,NonFungibleToken.CollectionPublic,NonFungibleToken.Provider,MetadataViews.ResolverCollection}>(), 
              createEmptyCollectionFunction: (fun (): @NonFungibleToken.Collection {
                  return <-GachaNFT.createEmptyCollection()
              })
            )
          // NFTコレクションを表示するのに必要な情報
          case Type<MetadataViews.NFTCollectionDisplay>():
            let media = MetadataViews.Media(
              file: MetadataViews.IPFSFile(
                cid: "QmTA3bk8GiXDnNdtLKWzXVGQxNqbfQv7WKZ7YoqCHCs6bJ",
                path: nil
              ),
              mediaType: "image/svg+xml"
            )
            return MetadataViews.NFTCollectionDisplay(
                name: "GachaNFT Collection",
                description: "This collection has Gacha feature.",
                externalURL: MetadataViews.ExternalURL("https://xxxxx"),
                squareImage: media, // コレクションのスクエア画像
                bannerImage: media, // コレクションのバナー画像
                // SNSなど
                socials: {
                    "twitter": MetadataViews.ExternalURL("https://twitter.com/xxxxxx")
                }
            )
          // key-valueで取り出せる属性的なやつ
          case Type<MetadataViews.Traits>():
            // exclude mintedTime and foo to show other uses of Traits
            let excludedTraits = ["mintedTime", "foo"]
            let traitsView = MetadataViews.dictToTraits(dict: self.metadata, excludedNames: excludedTraits)

            // mintedTime is a unix timestamp, we should mark it with a displayType so platforms know how to show it.
            let mintedTimeTrait = MetadataViews.Trait(name: "mintedTime", value: self.metadata["mintedTime"]!, displayType: "Date", rarity: nil)
            traitsView.addTrait(mintedTimeTrait)

            // foo is a trait with its own rarity
            let fooTraitRarity = MetadataViews.Rarity(score: 10.0, max: 100.0, description: "Common")
            let fooTrait = MetadataViews.Trait(name: "foo", value: self.metadata["foo"], displayType: nil, rarity: fooTraitRarity)
            traitsView.addTrait(fooTrait)
            
            return traitsView
        }
        return nil
      }
    }

    // publicに公開する機能群
    pub resource interface GachaNFTCollectionPublic {
      pub fun deposit(token: @NonFungibleToken.NFT)
      pub fun getIDs(): [UInt64]
      pub fun borrowNFT(id: UInt64): &NonFungibleToken.NFT
      pub fun borrowGachaNFT(id: UInt64): &GachaNFT.NFT? {
          post {
              (result == nil) || (result?.id == id):
                  "Cannot borrow ExampleNFT reference: the ID of the returned reference is incorrect"
          }
      }
    }

    // NonFungibleToken override
    pub resource Collection: 
      GachaNFTCollectionPublic,
      NonFungibleToken.Provider,
      NonFungibleToken.Receiver,  
      NonFungibleToken.CollectionPublic, 
      MetadataViews.ResolverCollection,
      Gacha.IncreceAmount,
      Gacha.DecreceAmount,
      Gacha.GetAmounts
    {
      // NonFungibleToken.Collection override
      pub var ownedNFTs: @{UInt64: NonFungibleToken.NFT}

      pub var ownedAmounts: {UInt64: UInt32}

      init() {
        self.ownedNFTs <- {}
        self.ownedAmounts = {}
      } 

      pub fun increceAmount(id: UInt64, amount: UInt32) {
        let beforeAmount = self.ownedAmounts[id] ?? panic("Does Not have token, so instedof deposit!")
        let afterAmount = beforeAmount + amount
        self.ownedAmounts[id] = afterAmount

        emit Increce(id: id, beforeAmount: beforeAmount, afterAmount: afterAmount)
      }

      pub fun decreseAmount(id: UInt64, amount: UInt32) {
        let beforeAmount = self.ownedAmounts[id] ?? panic("Does Not have token!")
        let afterAmount = beforeAmount - amount
        self.ownedAmounts[id] = afterAmount

        emit Decrece(id: id, beforeAmount: beforeAmount, afterAmount: afterAmount)

        if(afterAmount == 0) {
          // なくなったのでリソースも消す
          destroy self.withdraw(withdrawID: id)
        }
      }

      pub fun getAmount(id: UInt64): UInt32 {
        return self.ownedAmounts[id] ?? 0
      }

      pub fun getAmounts(): {UInt64:UInt32} {
        return self.ownedAmounts
      }

      // NonFungibleToken.Provider override
      pub fun withdraw(withdrawID: UInt64): @NonFungibleToken.NFT {
        let token <- self.ownedNFTs.remove(key: withdrawID) ?? panic("missing NFT")
        self.ownedAmounts.remove(key: withdrawID)               

        emit Withdraw(id: token.id, from: self.owner?.address)

        return <-token
      }

      // NonFungibleToken.Receiver override
      pub fun deposit(token: @NonFungibleToken.NFT) {
        pre {
          self.ownedAmounts[token.id] == nil || self.ownedAmounts[token.id]! <= 0: "Already owned!"
        }
        let token <- token as! @GachaNFT.NFT // important! castする必要がある
        let id: UInt64 = token.id

        // add the new token to the dictionary which removes the old one
        let oldToken <- self.ownedNFTs[id] <- token

        self.ownedAmounts[id] = 1

        emit Deposit(id: id, to: self.owner?.address)

        destroy oldToken
      }

      // NonFungibleToken.CollectionPublic override
      pub fun getIDs(): [UInt64] {
          return self.ownedNFTs.keys
      }

      // NonFungibleToken.CollectionPublic override
      pub fun borrowNFT(id: UInt64): &NonFungibleToken.NFT {
          return (&self.ownedNFTs[id] as &NonFungibleToken.NFT?)!
      }
 
      // GachaNFTCollectionPublic override
      pub fun borrowGachaNFT(id: UInt64): &GachaNFT.NFT? {
          if self.ownedNFTs[id] != nil {
              // Create an authorized reference to allow downcasting
              let ref = (&self.ownedNFTs[id] as auth &NonFungibleToken.NFT?)!
              return ref as! &GachaNFT.NFT
          }

          return nil
      }

      // MetadataViews.ResolverCollection override
      pub fun borrowViewResolver(id: UInt64): &AnyResource{MetadataViews.Resolver} {
          let nft = (&self.ownedNFTs[id] as auth &NonFungibleToken.NFT?)!
          let gachaNFT = nft as! &GachaNFT.NFT
          return gachaNFT
      }

      destroy() {
          destroy self.ownedNFTs
          self.ownedAmounts = {}
      }
    }

    pub resource NFTMinter {
    
      pub fun mint(
        recipient: &{NonFungibleToken.CollectionPublic},
        royalties: [MetadataViews.Royalty],
        item: Item,
      ) {
          let metadata: {String: AnyStruct} = {}
          let currentBlock = getCurrentBlock()
          metadata["mintedBlock"] = currentBlock.height
          metadata["mintedTime"] = currentBlock.timestamp
          metadata["minter"] = recipient.owner!.address

          // create a new NFT
          var newNFT <- create NFT(
              id: item.id,
              name: item.name,
              description: item.description,
              thumbnail: item.thumbnail,
              royalties: royalties,
              metadata: metadata,
          )

          // deposit it in the recipient's account using their reference
          recipient.deposit(token: <-newNFT)

          GachaNFT.totalSupply = GachaNFT.totalSupply + 1
      }
    }

    pub fun createNFTMinter(): @NFTMinter {
      return <- create NFTMinter()
    }

    init() {
      self.totalSupply = 0
      
      self.CollectionStoragePath = StoragePath(identifier: "GachaNFTCollection") ?? panic("can not specify storage path.")
      self.CollectionPublicPath = PublicPath(identifier: "GachaNFTCollection") ?? panic("can not specify public path.")
      self.MinterStoragePath = StoragePath(identifier: "GachaNFTMinter") ?? panic("can not specify storage path.")
      self.GachaPublicPath = PublicPath(identifier: "GachaPublic") ?? panic("can not specify public path.")

      // TODO コントラクタ引数にする
      self.ids = {
        1: Item(
          id: 1, name: "Item1", description: "Normal item.", thumbnail: "QmSzzQjaQSsUgYpxXxtF1mRgUzFYKh5HZQRi2RehNs8ZhH", rarity: "N", weight: 60
        ),
        2: Item(
          id: 2, name: "Item2", description: "Rea item.", thumbnail: "QmeHqCZ2M3FJa1J91Rd8arhKj5UBAmbs4i3mHxs6QVz6xS", rarity: "R", weight: 30
        ),
        3: Item(
          id: 3, name: "Item3", description: "Super Rea item.", thumbnail: "QmQCrYirym911cBSygYX84sWmUmirtRqpXiZFVr67s5pm7", rarity: "SR", weight: 10
        )
      }
    }
}

mintしてみる

まずはアカウントのセットアップをします。

setup_account.cdc
import GachaNFT from "../contracts/GachaNFT.cdc"
import Gacha from "../contracts/Gacha.cdc"
import NonFungibleToken from "../contracts/lib/NonFungibleToken.cdc"

transaction {
  prepare(signer: AuthAccount) {
    // 既にコレクションを持っている
    if signer.borrow<&GachaNFT.Collection>(from: GachaNFT.CollectionStoragePath) != nil {
      return
    }

    // リソース作成
    let collection <- GachaNFT.createEmptyCollection()

    // リソース保存
    signer.save(<- collection, to: GachaNFT.CollectionStoragePath)

    // linkの作成
    signer.link<&{NonFungibleToken.CollectionPublic}>(
      GachaNFT.CollectionPublicPath,
      target: GachaNFT.CollectionStoragePath
    )

    signer.link<&{Gacha.IncreceAmount, Gacha.GetAmounts}>(
      GachaNFT.GachaPublicPath,
      target: GachaNFT.CollectionStoragePath
    )

    log("complete setup!!")
  }
}
setup_nft_minter.cdc
/*
管理者アカウントで実行する
NFTをmintするためのminterリソースをストレージに保存する
 */
import GachaNFT from "../contracts/GachaNFT.cdc"
import NonFungibleToken from "../contracts/lib/NonFungibleToken.cdc"

transaction {
  prepare(signer: AuthAccount) {
    let minter <- GachaNFT.createNFTMinter()
    signer.save(<- minter, to: GachaNFT.MinterStoragePath)
    log("complete setup minter!!")
  }
}

準備ができたらmintしてみます。

lottery_mint.cdc
import GachaNFT from "../contracts/GachaNFT.cdc"
import Gacha from "../contracts/Gacha.cdc"
import NonFungibleToken from "../contracts/lib/NonFungibleToken.cdc"

transaction(
    recipient: Address
) {
    let minter: &GachaNFT.NFTMinter
    let recipientCollectionRef: &{NonFungibleToken.CollectionPublic}
    let gachaRef: &{Gacha.IncreceAmount, Gacha.GetAmounts}

    prepare(acct: AuthAccount) {
        self.minter = acct.borrow<&GachaNFT.NFTMinter>(from: GachaNFT.MinterStoragePath)
            ?? panic("Account does not store minter object at the specify storage path")
        
        self.recipientCollectionRef = getAccount(recipient)
            .getCapability(GachaNFT.CollectionPublicPath)
            .borrow<&{NonFungibleToken.CollectionPublic}>()
            ?? panic("Account does not store collection object at the specify public path")
        
        self.gachaRef = getAccount(recipient)
            .getCapability(GachaNFT.GachaPublicPath)
            .borrow<&{Gacha.IncreceAmount, Gacha.GetAmounts}>()
            ?? panic("Account does not store collection object at the specify public path")

    }

    execute {
        // アイテムと重みの対応表
        let ids = GachaNFT.ids

        // 重みの総和
        var total: UInt64 = 0
        ids.forEachKey(fun (id: UInt64): Bool {
            let item = ids[id]
            if item != nil {
                total = total + item!.weight
                return true
            } else {
                return false
            }
        })

        // 乱数
        let rand = unsafeRandom() % total // 0 ~ (total - 1)までの乱数
        // 重み付け抽選
        var currentWeight: UInt64 = 0
        var lotteryItem: GachaNFT.Item? = nil
        for i, key in ids.keys {
            let item = ids[key]!
            currentWeight = currentWeight + item.weight
            if rand < currentWeight {
                lotteryItem = item as? GachaNFT.Item ?? panic("LotteryItem type is not GachaNFT.Item!!")
                break
            }
        }

        // たぶんありえない
        if lotteryItem == nil {
            panic("Fail lottery NFT!")
        }

        if self.gachaRef.getAmount(id: lotteryItem!.id) == 0 {
            // まだ持ってないトークンなので普通にmintする
            self.minter.mint(
                recipient: self.recipientCollectionRef,
                royalties: [],
                item: lotteryItem!
            )
            log("execute mint!!")
        } else {
            // 既に持ってるので個数を増やす
            self.gachaRef.increceAmount(id: lotteryItem!.id, amount: 1)
            log("increce item amount!!")
        }

        log("complete lottery!!")
    }
}

今回NFTガチャというコントラクトを作りましたが実際の抽選処理はtransactionの処理内に実装しています。本当はコントラクト内で抽選までできてれば良かったのですがコントラクト内で乱数を使用する際に安全に利用するのが難しいです。コントラクト内での乱数の扱いについては以下の記事が大変勉強になりましたので詳しく知りたい方はご参照ください。

https://medium.com/flow-japan/flow-nft-random-parameter-f8a6baf95dd9

テストネットにデプロイする

最後に実装したコントラクトをテストネットにデプロイします。flow.jsonに以下のようにデプロイ用アカウントとテストネットへデプロイするコントラクトを記載します。デプロイ用のアカウントがなければ以下のコマンドで作成します。

flow accounts create 
flow.json
{
    ...,
    "accounts": {
      "gacha-nft-deploy-account": {
        "address": "5a9a22c936e8866e",
        "key": {
          "type": "file",
          "location": "gacha-nft-deploy-account.pkey"
        }
      },
    },
  	"deployments": {
      "testnet": {
        "gacha-nft-deploy-account": [
          "Gacha",
          "GachaNFT",
          "FungibleToken",
          "MetadataViews",
          "NonFungibleToken"
        ]
      }
    }
}

追記できたら以下のコマンドを実行してコントラクトをデプロイします。

flow project deploy --network=testnet

フロントエンドの実装

今回フロントエンドにはNext.jsを使用し実装したコントラクトとの通信をしてみます。

セットアップ

init

npx create-next-app@latest nft-gacha-web

FlowからJSのクライアントライブラリとしてFCLが提供されているのでインストールします。

npm install @onflow/fcl --save

FCLを使用する場合、FCLの設定ファイルの配置が必要になります。プロジェクトのルートディレクトリにflow/config.jsを作成し以下のように記載します。

config.js
import { config } from '@onflow/fcl';

config({
  'accessNode.api': 'https://rest-testnet.onflow.org', // Mainnet: "https://rest-mainnet.onflow.org"
  'discovery.wallet': 'https://fcl-discovery.onflow.org/testnet/authn', // Mainnet: "https://fcl-discovery.onflow.org/authn"
  '0xGacha': '0x5a9a22c936e8866e',
});

今回実装したNFTガチャのコントラクトアドレスを0xGachaというキーに指定しています。0xというプレフィックスで設定することで後述するtransactionやscriptに指定するcadenceコード内のimportで0xGachaという名前でimportできるようになります。

ウォレット接続処理

Flowにおけるウォレット接続はFCLを利用することで非常に簡単に実装することができます。以下のようなカスタムフックスを作成します。

useConnect.ts
import { useEffect, useState } from 'react';
import * as fcl from '@onflow/fcl';

type User = {
  loggedIn?: boolean;
  addr?: string;
};

export const useConnect = () => {
  const [user, setUser] = useState<User>({
    loggedIn: undefined,
    addr: undefined,
  });

  useEffect(() => fcl.currentUser.subscribe(setUser), []);

  return {
    user,
    unauthenticate: fcl.unauthenticate,
    logIn: fcl.logIn,
    signUp: fcl.signUp,
  };
};

importしたfclからsubscribe(), unauthenticate(), logIn(), signUp()を使用しています。

Headerコンポーネントで以下のように使用します。

Header.tsx
import Head from 'next/head';
import { useConnect } from '../hooks/useConnect';

const Header = () => {
  const { user, unauthenticate, logIn, signUp } = useConnect();

  // 認証済み
  const AuthedState = () => {
    return (
      <div className="flex items-center">
        <div className="p-3">My Address: {user?.addr ?? 'No Address'}</div>
        <button
          onClick={unauthenticate}
          className="m-4 ml-2 cursor-pointer rounded border-none bg-blue-700 p-3 hover:bg-blue-300"
        >
          Log Out
        </button>
      </div>
    );
  };

  // 未認証
  const UnauthenticatedState = () => {
    return (
      <div className="flex items-center">
        <button
          onClick={logIn}
          className="m-4 cursor-pointer rounded border-none bg-blue-700 p-3 hover:bg-blue-300"
        >
          Log In
        </button>
        <button
          onClick={signUp}
          className="m-4 cursor-pointer rounded border-none bg-blue-700 p-3 hover:bg-blue-300"
        >
          Sign Up
        </button>
      </div>
    );
  };

  return (
    <div className="flex h-20 justify-between bg-slate-700 align-middle">
      <Head>
        <title>FCL Quickstart with NextJS</title>
        <meta name="description" content="My first web3 app on Flow!" />
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <div className="flex items-center">
        <h1 className="p-2 text-3xl text-blue-300">Flow App</h1>
      </div>
      {user.loggedIn ? <AuthedState /> : <UnauthenticatedState />}
    </div>
  );
};

export default Header;

signInもしくはsignUp関数を呼び出すことでウォレット接続が自動で呼び出されます。特に設定しなければblocto, Lilicoが選択できますのでまだなければどちらかのウォレットで接続できるようにします。

loginに成功するとuser変数にログイン状態とアドレスが設定されます。

スマートコントラクトとの通信処理

fclを使用することでデプロイしたスマートコントラクトにscriptやtransactionを送信することができます。例えば、scriptを実行するには以下のようにfcl.query()にcadenceコードをパラメーターとして渡します。

    // コレクションのアイテム数を取得する
  const getAmounts = async (addr: string) => {
    try {
      const items = await fcl.query({
        cadence: `
          import GachaNFT from 0xGacha
          import Gacha from 0xGacha
          pub fun main(address: Address): {UInt64: UInt32} {
            let account = getAuthAccount(address)
            let ref = account.borrow<&GachaNFT.Collection>(from: GachaNFT.CollectionStoragePath) ?? panic("Does not store collection at the storage path.")
            return ref.getAmounts()
          }
        `,
        args: (arg: any, t: any) => [arg(addr, t.Address)],
      });
      return items;
    } catch (e: unknown) {
      console.log(e);
    }
  };

toransactionを送信する場合はmutate()を使用します。

  // setup minter
  const setupMinter = async (): Promise<Transaction> => {
    const transactionId = await fcl.mutate({
      cadence: `
        import GachaNFT from 0xGacha
        import NonFungibleToken from 0xGacha
        transaction {
          prepare(signer: AuthAccount) {
            let minter <- GachaNFT.createNFTMinter()
            signer.save(<- minter, to: GachaNFT.MinterStoragePath)
            log("complete setup minter!!")
          }
        }
      `,
      proposer: fcl.currentUser,
      payer: fcl.currentUser,
      authorizations: [fcl.currentUser],
      limit: 50,
    });
    const transaction: Transaction = await fcl.tx(transactionId).onceSealed();
    return transaction;
  };

まとめ

  • Flowチェーン上にNFTスマートコントラクトを実装してデプロイする流れを紹介しました。
  • FCLを使用したフロントアプリケーションの作成の流れを紹介しました。

執筆時点でまだFCLのTypeScriptサポートがされていなかったのが残念でしたが、それ以外はFCLは使用しやすく、簡単にスマートコントラクトと通信が可能でした。コントラクトの実装はCadenceという言語の恩恵が強く、かなり自由度高くコントラクト設計ができそうだなと感じました。

今回は学習用途で作成しましたがちゃんとプロダクト利用できるコントラクトを実装できるよう今後もCadenceとFlowの情報は追っていきたいなと思います。以上!

今回の成果物はこちらになります

https://github.com/JY8752/Gacha-NFT-collection

GitHubで編集を提案

Discussion