FlowチェーンにNFTガチャを実装してデプロイする
Flowチェーンのテストネットにスマートコントラクトをデプロイしてwebフロントから接続してみたのでその備忘録です。作成するのは抽選アイテムをもったスマートコントラクトでランダムに抽選してアイテムをmintします。NFTのガチャのイメージです。
Flow、Cadenceについての詳細な説明は本記事では行いません。Cadenceについて詳しく知りたい方は公式ドキュメントを参照していただくかわたしが執筆したzenn本がありますのでよければご参照ください。
スマートコントラクトの作成
まずはFlow CLIで雛形を作成します。
flow setup gacha-nft
ガチャインターフェイスを作成
ガチャコントラクトを作成するための処理をインターフェイスを作成することで抽象化します。インターフェイスに実装する機能は以下の通りです。
Item
ガチャのアイテム抽選には排出アイテムそれぞれに一定の重みを設定し、抽選時に重み付け抽選ができるようにします。各アイテムはidを割り振りkeyにid, valueにアイテムの重みとした辞書型の変数をコントラクトに持たせます。
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リソースの定義をします。ユーザーがどのアイテムをいくつ持っているかの情報と数量の増減、数量の取得関数の定義をします。
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}
}
ファイルの全体は以下のような感じです。
/// 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を実装する場合、既に用意されているコントラクトをいくつか使用することができます。
今回はこれらのコントラクトからNonFungibleToken
, MetadataViews
, FungibleToken
をコピーして配置します。
.
├── contracts
│ ├── Gacha.cdc
│ └── lib
│ ├── MetadataViews.cdc
│ ├── NonFungibleToken.cdc
│ └── utility
│ └── FungibleToken.cdc
ガチャコントラクトの実装
作成したインターフェイスをコントラクトに実装します。
アイテムを抽選するためのidsフィールド
以下のようなidとItemの構造体の対応表をフィールドで保持させ、抽選に利用します。
/// 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やサムネイル画像などの情報を持たせる。
// 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の所持数などを保持します。
// 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リソースを以下のように定義します。
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
}
}
コントラクトの初期化
コントラクトの初期化処理は以下のように定義します。本当はガチャの排出アイテムの初期化にはコントラクトデプロイ時の引数などで渡すようにしたかったのですが一旦初期化処理にハードコーディングしています。
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
)
}
}
コントラクトの最終的な実装はこちらです。
コントラクトの最終的な実装
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してみる
まずはアカウントのセットアップをします。
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!!")
}
}
/*
管理者アカウントで実行する
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してみます。
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の処理内に実装しています。本当はコントラクト内で抽選までできてれば良かったのですがコントラクト内で乱数を使用する際に安全に利用するのが難しいです。コントラクト内での乱数の扱いについては以下の記事が大変勉強になりましたので詳しく知りたい方はご参照ください。
テストネットにデプロイする
最後に実装したコントラクトをテストネットにデプロイします。flow.jsonに以下のようにデプロイ用アカウントとテストネットへデプロイするコントラクトを記載します。デプロイ用のアカウントがなければ以下のコマンドで作成します。
flow accounts create
{
...,
"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
を作成し以下のように記載します。
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を利用することで非常に簡単に実装することができます。以下のようなカスタムフックスを作成します。
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コンポーネントで以下のように使用します。
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の情報は追っていきたいなと思います。以上!
今回の成果物はこちらになります
Discussion