gnowardrobe.gno
9.62 Kb · 339 lines
1// Package gnodrobe — GnoDrobe Collection, GRC-721 NFT minter realm.
2//
3// Пользователь собирает гнома (hat + beard) на сайте GnoDrobe и вызывает
4// Mint(hatID, beardID), прикладывая к вызову ровно
5// (hatPrice + beardPrice) ugnot. Средства сразу уходят владельцу
6// коллекции (collectionOwner), а минтер получает NFT с on-chain
7// метаданными (OpenSea-совместимый data URI: имя, описание, трейты).
8//
9// Ограничения: maxSupply (общий тираж), maxMintsPerWallet (лимит на
10// один кошелёк), royalty 1% (декларируется в метаданных; полноценный
11// сбор роялти при вторичных продажах — на маркетплейсе).
12package gnowardrobe
13
14import (
15 "chain"
16 "chain/banker"
17 runtime "chain/runtime"
18 unsaferealm "chain/runtime/unsafe"
19 "strconv"
20 "strings"
21
22 grc721 "gno.land/p/g1gn6t0q9wenwhdda47rkrpfd63kcxjvyp7eqwku/grc721v2"
23 "gno.land/p/nt/avl/v0"
24 "gno.land/p/nt/ufmt/v0"
25)
26
27const (
28 CollectionName = "GnoDrobe Collection"
29 CollectionSymbol = "GDG"
30
31 // MaxSupply — общий тираж коллекции (жёсткий потолок минтов).
32 MaxSupply = 10000
33
34 // MaxMintsPerWallet — сколько NFT максимум на один кошелёк.
35 MaxMintsPerWallet = 10
36
37 // RoyaltyPct — роялти с вторичных продаж, 1%.
38 RoyaltyPct = 1
39
40 // collectionOwner — владелец коллекции, получает платежи за минт.
41 // = Harry (деплойер). Менять только owner-функцией SetOwner.
42 collectionOwner = "g19sh6ww9g6ukzhndg4d4zkx25l6nq782f9pfuap"
43
44 // realmRelPath — путь реалма (для рендера).
45 realmRelPath = "/r/g19sh6ww9g6ukzhndg4d4zkx25l6nq782f9pfuap/gnowardrobe/v2"
46)
47
48// Part — одна часть гнома (шапка или борода) с ценой в GNOT.
49type Part struct {
50 Name string
51 Price int64 // цена в GNOT (не ugnot)
52}
53
54// hats/beards — каталог ассетов GnoDrobe с ценами из assets-manifest.json.
55// Индекс = ID, как в манифесте фронтенда. Порядок менять нельзя —
56// сломает соответствие с UI.
57var hats = []Part{
58 {"Cardboard Crown", 2},
59 {"Cosmic", 4},
60 {"Crystal", 6},
61 {"Denim", 2},
62 {"Golden", 4},
63 {"Grassy", 6},
64 {"Jelly", 2},
65 {"Leather", 4},
66 {"Metallic", 6},
67 {"Noodle", 2},
68 {"Plank", 4},
69 {"Spruce", 6},
70 {"Straw", 2},
71 {"Volcanic", 4},
72}
73
74var beards = []Part{
75 {"Bone", 4},
76 {"Cloudy", 6},
77 {"Coin", 2},
78 {"Cosmic", 4},
79 {"Crystal", 6},
80 {"Leafy", 2},
81 {"Lightning", 4},
82 {"Metallic", 6},
83 {"Mushroom", 2},
84 {"Piped", 4},
85}
86
87var (
88 nft *grc721.MetadataNFT
89 owner address = collectionOwner
90 mintingOpen = true
91 mintsByWallet = avl.NewTree() // addr -> int64 (сколько наминтил)
92)
93
94func init(cur realm) {
95 nft = grc721.NewNFTWithMetadata(0, cur, CollectionName, CollectionSymbol)
96}
97
98// ---- Owner / конфиг ----
99
100func Owner() address { return owner }
101
102func SetOwner(cur realm, newOwner address) {
103 if callerAddress() != owner {
104 panic("only the collection owner can change the owner")
105 }
106 if newOwner == "" {
107 panic("owner cannot be empty")
108 }
109 owner = newOwner
110}
111
112func MintingOpen() bool { return mintingOpen }
113
114func SetMintingOpen(cur realm, open bool) {
115 if callerAddress() != owner {
116 panic("only the collection owner can change minting")
117 }
118 mintingOpen = open
119}
120
121// WithdrawFunds отправляет amount ugnot с баланса реалма владельцу.
122func WithdrawFunds(cur realm, amountUgnot int64) {
123 if callerAddress() != owner {
124 panic("only the collection owner can withdraw")
125 }
126 if amountUgnot <= 0 {
127 panic("amount must be positive")
128 }
129 b := banker.NewBanker(banker.BankerTypeRealmSend, cur)
130 b.SendCoins(cur.Address(), owner, chain.Coins{{Denom: "ugnot", Amount: amountUgnot}})
131}
132
133// ---- Каталог ----
134
135func HatCount() int { return len(hats) }
136func BeardCount() int { return len(beards) }
137
138func HatName(id int) string {
139 if id < 0 || id >= len(hats) {
140 return ""
141 }
142 return hats[id].Name
143}
144
145func BeardName(id int) string {
146 if id < 0 || id >= len(beards) {
147 return ""
148 }
149 return beards[id].Name
150}
151
152func HatPriceUgnot(id int) int64 {
153 if id < 0 || id >= len(hats) {
154 return 0
155 }
156 return hats[id].Price * 1_000_000
157}
158
159func BeardPriceUgnot(id int) int64 {
160 if id < 0 || id >= len(beards) {
161 return 0
162 }
163 return beards[id].Price * 1_000_000
164}
165
166// ---- Минт ----
167
168// Mint чеканит NFT гнома из hatID+beardID вызывающему.
169// К вызову нужно приложить ровно (HatPriceUgnot(hatID)+BeardPriceUgnot(beardID)).
170func Mint(cur realm, hatID, beardID int) string {
171 caller := callerAddress()
172
173 if !mintingOpen {
174 panic("minting is closed")
175 }
176 if nft.TokenCount() >= MaxSupply {
177 panic("max supply reached")
178 }
179 if maxPerWalletReached(caller) {
180 panic("wallet mint limit reached")
181 }
182 if hatID < 0 || hatID >= len(hats) {
183 panic("invalid hat id")
184 }
185 if beardID < 0 || beardID >= len(beards) {
186 panic("invalid beard id")
187 }
188
189 priceUgnot := HatPriceUgnot(hatID) + BeardPriceUgnot(beardID)
190
191 // Оплата: приложенные ugnot должны ровно совпадать с ценой.
192 if priceUgnot > 0 {
193 runtime.AssertOriginCall()
194 if !unsaferealm.PreviousRealm().IsUserCall() {
195 panic("payment verification requires a direct user call")
196 }
197 got := unsaferealm.OriginSend().AmountOf("ugnot")
198 if got != priceUgnot {
199 panic(ufmt.Sprintf(
200 "payment required: exactly %d ugnot, got %d",
201 priceUgnot, got,
202 ))
203 }
204 // Сразу пересылаем владельцу.
205 b := banker.NewBanker(banker.BankerTypeRealmSend, cur)
206 b.SendCoins(cur.Address(), owner, chain.Coins{{Denom: "ugnot", Amount: got}})
207 }
208
209 tid := grc721.TokenID(strconv.FormatInt(nft.TokenCount()+1, 10))
210 checkErr(nft.Mint(caller, tid))
211 checkErr(nft.SetTokenMetadata(caller, tid, grc721.Metadata{
212 Name: ufmt.Sprintf("%s #%s", CollectionName, tid.String()),
213 Description: "A gnome from the GnoDrobe Collection, assembled from hat + beard on-chain traits.",
214 Attributes: []grc721.Trait{
215 {TraitType: "Hat", Value: hats[hatID].Name},
216 {TraitType: "Beard", Value: beards[beardID].Name},
217 {TraitType: "Royalty", Value: "1%"},
218 },
219 }))
220 incMintCount(caller)
221
222 chain.Emit(
223 "GnoDrobeMinted",
224 "tokenId", tid.String(),
225 "hat", hats[hatID].Name,
226 "beard", beards[beardID].Name,
227 "to", caller.String(),
228 )
229
230 return tid.String()
231}
232
233// ---- View-функции для фронтенда ----
234
235func Name() string { return nft.Name() }
236func Symbol() string { return nft.Symbol() }
237func TotalMinted() int64 { return nft.TokenCount() }
238
239// GetMaxSupply — общий тираж (константа MaxSupply).
240func GetMaxSupply() int64 { return MaxSupply }
241
242func TokenURI(tid grc721.TokenID) (string, error) { return nft.TokenURI(tid) }
243func OwnerOf(tid grc721.TokenID) (address, error) { return nft.OwnerOf(tid) }
244func BalanceOf(addr address) (int64, error) { return nft.BalanceOf(addr) }
245func TokenMetadata(tid grc721.TokenID) (grc721.Metadata, error) {
246 return nft.TokenMetadata(tid)
247}
248
249// TokensOf возвращает список tokenID, принадлежащих addr (по возрастанию).
250// Реализовано через обход всех токенов — для тестнета норм
251// (макс 10k токенов, qeval читает без газа).
252func TokensOf(addr address) []string {
253 out := []string{}
254 for i := int64(1); i <= nft.TokenCount(); i++ {
255 tid := grc721.TokenID(strconv.FormatInt(i, 10))
256 o, err := nft.OwnerOf(tid)
257 if err == nil && o == addr {
258 out = append(out, tid.String())
259 }
260 }
261 return out
262}
263
264// ---- Рендер ----
265
266func Render(path string) string {
267 var b strings.Builder
268 b.WriteString("# GnoDrobe Collection\n\n")
269 b.WriteString(ufmt.Sprintf("Symbol: %s\n", CollectionSymbol))
270 b.WriteString(ufmt.Sprintf("Minted: %d / %d\n", nft.TokenCount(), MaxSupply))
271 b.WriteString(ufmt.Sprintf("Owner: %s\n", owner.String()))
272 b.WriteString(ufmt.Sprintf("Royalty: %d%%\n", RoyaltyPct))
273 b.WriteString(ufmt.Sprintf("Mint price: %d ugnot (hat) + %d ugnot (beard) per part\n\n",
274 hats[0].Price*1_000_000, beards[0].Price*1_000_000))
275
276 b.WriteString("## Parts\n\n")
277 b.WriteString("### Hats\n")
278 for i, h := range hats {
279 b.WriteString(ufmt.Sprintf("%d. %s — %d GNOT\n", i, h.Name, h.Price))
280 }
281 b.WriteString("\n### Beards\n")
282 for i, bd := range beards {
283 b.WriteString(ufmt.Sprintf("%d. %s — %d GNOT\n", i, bd.Name, bd.Price))
284 }
285 b.WriteString("\n## Tokens\n")
286 for i := int64(1); i <= nft.TokenCount(); i++ {
287 tid := grc721.TokenID(strconv.FormatInt(i, 10))
288 o, err := nft.OwnerOf(tid)
289 if err != nil {
290 continue
291 }
292 md, err := nft.TokenMetadata(tid)
293 if err != nil {
294 continue
295 }
296 hat := ""
297 beard := ""
298 for _, t := range md.Attributes {
299 if t.TraitType == "Hat" {
300 hat = t.Value
301 }
302 if t.TraitType == "Beard" {
303 beard = t.Value
304 }
305 }
306 b.WriteString(ufmt.Sprintf("- #%s: %s + %s, owner %s\n",
307 tid.String(), hat, beard, o.String()))
308 }
309 return b.String()
310}
311
312// ---- Хелперы ----
313
314func callerAddress() address {
315 return unsaferealm.PreviousRealm().Address()
316}
317
318func maxPerWalletReached(addr address) bool {
319 v := mintsByWallet.Get(addr.String())
320 if v == nil {
321 return false
322 }
323 return v.(int64) >= MaxMintsPerWallet
324}
325
326func incMintCount(addr address) {
327 v := mintsByWallet.Get(addr.String())
328 if v == nil {
329 mintsByWallet.Set(addr.String(), int64(1))
330 return
331 }
332 mintsByWallet.Set(addr.String(), v.(int64)+1)
333}
334
335func checkErr(err error) {
336 if err != nil {
337 panic(err)
338 }
339}