reveal.gno
7.30 Kb · 254 lines
1package guardians
2
3import (
4 "chain"
5 "chain/runtime"
6 "crypto/sha256"
7 "encoding/hex"
8 "strconv"
9 "strings"
10 "time"
11
12 "gno.land/p/nt/grc721/v0"
13 "gno.land/p/nt/ufmt/v0"
14)
15
16const (
17 revealStateVeiled = "VEILED"
18 revealStateCommitted = "REVEAL_COMMITTED"
19 revealStateRevealing = "REVEALING"
20 revealStateRevealed = "REVEALED"
21 revealStateUnveiledFrozen = "UNVEILED_FROZEN"
22 maxRevealBatch = 20
23)
24
25var (
26 revealState = revealStateVeiled
27 provenanceRoot string
28 revealCommitment string
29 finalBaseURI string
30 revealDeadline int64
31 revealSeedHash string
32 permutationA int64
33 permutationB int64
34 revealTarget int64
35 revealProcessed int64
36)
37
38func RevealState() string { return revealState }
39
40func ProvenanceRoot() string { return provenanceRoot }
41
42func RevealCommitment() string { return revealCommitment }
43
44func FinalBaseURI() string { return finalBaseURI }
45
46func RevealDeadline() int64 { return revealDeadline }
47
48func RevealSeedHash() string { return revealSeedHash }
49
50func RevealTarget() int64 { return revealTarget }
51
52func RevealProcessed() int64 { return revealProcessed }
53
54func RevealOverdue() bool {
55 return revealState == revealStateCommitted && time.Now().Unix() > revealDeadline
56}
57
58// ConfigureReveal permanently commits provenance, secret hash and final URI
59// layout before the first token can be minted.
60func ConfigureReveal(cur realm, provenance, commitment, baseURI string, deadline int64) {
61 assertAdmin(cur.Previous().Address())
62 if revealState != revealStateVeiled {
63 panic("guardians: reveal configuration already committed")
64 }
65 if token.TotalSupply() != 0 || !salePaused {
66 panic("guardians: configure reveal before minting and sale activation")
67 }
68 normalizedProvenance := normalizeHash(provenance)
69 if normalizedProvenance == "" {
70 panic("guardians: invalid provenance root")
71 }
72 normalizedCommitment := normalizeHash(commitment)
73 if normalizedCommitment == "" {
74 panic("guardians: invalid reveal commitment")
75 }
76 if !validFinalBaseURI(baseURI) {
77 panic("guardians: final base uri must be ipfs/https and end with slash")
78 }
79 if deadline <= time.Now().Unix() {
80 panic("guardians: reveal deadline must be in the future")
81 }
82
83 provenanceRoot = normalizedProvenance
84 revealCommitment = normalizedCommitment
85 finalBaseURI = baseURI
86 revealDeadline = deadline
87 revealState = revealStateCommitted
88
89 chain.Emit(
90 "RevealConfigured",
91 "provenance", provenanceRoot,
92 "commitment", revealCommitment,
93 "deadline", ufmt.Sprintf("%d", revealDeadline),
94 )
95}
96
97// ComputeRevealCommitment is deterministic but production secrets should be
98// committed with the offline helper, never exposed through a public RPC query.
99func ComputeRevealCommitment(secret string) string {
100 payload := runtime.ChainID() + "|" + CollectionID() + "|REVEAL|" + secret
101 return hashBytes([]byte(payload))
102}
103
104// Only the administrator can manually begin reveal after sale end or sellout.
105// Neither a deadline nor knowledge of the secret alone starts the reveal.
106func Reveal(cur realm, secret string) {
107 assertAdmin(cur.Previous().Address())
108 if revealState != revealStateCommitted {
109 panic("guardians: reveal is not committed")
110 }
111 if len(secret) < 8 || len(secret) > 256 {
112 panic("guardians: reveal secret length must be 8..256")
113 }
114 // publicEnd=0 means still open, so it must never unlock an early reveal.
115 ended := saleConfig.publicEnd > 0 && time.Now().Unix() >= saleConfig.publicEnd
116 if !saleConfigured || (!ended && token.TotalSupply() < maxSupply) {
117 panic("guardians: public sale has not ended")
118 }
119 if ComputeRevealCommitment(secret) != revealCommitment {
120 panic("guardians: reveal secret does not match commitment")
121 }
122 if token.TotalSupply() == 0 {
123 panic("guardians: cannot reveal an empty collection")
124 }
125
126 revealSeedHash = hashBytes([]byte("GUARDIANS|SEED|" + secret))
127 permutationA = derivePermutationA(secret)
128 permutationB = deriveSeedNumber("B", secret) % maxSupply
129 revealTarget = token.TotalSupply()
130 revealProcessed = 0
131 revealState = revealStateRevealing
132
133 chain.Emit(
134 "RevealStarted",
135 "seedHash", revealSeedHash,
136 "target", ufmt.Sprintf("%d", revealTarget),
137 )
138}
139
140// ApplyRevealBatch updates sequentially minted tokens. Anyone may execute the
141// deterministic batch, so the project operator cannot block progress.
142func ApplyRevealBatch(startTokenID int64, quantity int) {
143 if revealState != revealStateRevealing {
144 panic("guardians: reveal batch is not active")
145 }
146 if quantity < 1 || quantity > maxRevealBatch {
147 panic("guardians: reveal batch quantity must be 1..20")
148 }
149 if startTokenID != revealProcessed+1 {
150 panic("guardians: reveal batch must continue sequentially")
151 }
152 end := startTokenID + int64(quantity) - 1
153 if end > revealTarget {
154 panic("guardians: reveal batch exceeds target")
155 }
156
157 for tokenID := startTokenID; tokenID <= end; tokenID++ {
158 metadataID := MetadataID(tokenID)
159 uri := finalBaseURI + metadataFilename(metadataID)
160 if err := metadataLedger.SetTokenURI(grc721.TokenID(ufmt.Sprintf("%d", tokenID)), uri); err != nil {
161 panic(err)
162 }
163 }
164 revealProcessed = end
165 if revealProcessed == revealTarget {
166 revealState = revealStateRevealed
167 }
168
169 chain.Emit(
170 "RevealBatchApplied",
171 "startTokenId", ufmt.Sprintf("%d", startTokenID),
172 "endTokenId", ufmt.Sprintf("%d", end),
173 "processed", ufmt.Sprintf("%d", revealProcessed),
174 )
175}
176
177// metadataFilename matches the immutable IPFS release layout (0001.json–3333.json).
178func metadataFilename(metadataID int64) string {
179 serial := ufmt.Sprintf("%d", metadataID)
180 for len(serial) < 4 {
181 serial = "0" + serial
182 }
183 return serial + ".json"
184}
185
186func MetadataID(tokenID int64) int64 {
187 if revealState != revealStateRevealing &&
188 revealState != revealStateRevealed &&
189 revealState != revealStateUnveiledFrozen {
190 panic("guardians: metadata mapping is not revealed")
191 }
192 if tokenID < 1 || tokenID > revealTarget {
193 panic("guardians: token id outside reveal target")
194 }
195 return (permutationA*(tokenID-1)+permutationB)%maxSupply + 1
196}
197
198func FreezeMetadata(cur realm) {
199 assertAdmin(cur.Previous().Address())
200 if revealState != revealStateRevealed || revealProcessed != revealTarget {
201 panic("guardians: complete reveal before freezing metadata")
202 }
203 revealState = revealStateUnveiledFrozen
204 chain.Emit("MetadataFrozen", "provenance", provenanceRoot, "seedHash", revealSeedHash)
205}
206
207func assertMintingAllowed() {
208 if revealState != revealStateVeiled && revealState != revealStateCommitted {
209 panic("guardians: minting is closed after reveal starts")
210 }
211}
212
213func validFinalBaseURI(uri string) bool {
214 return (strings.HasPrefix(uri, "ipfs://") || strings.HasPrefix(uri, "https://")) &&
215 strings.HasSuffix(uri, "/")
216}
217
218func derivePermutationA(secret string) int64 {
219 candidate := deriveSeedNumber("A", secret) % maxSupply
220 if candidate == 0 {
221 candidate = 1
222 }
223 for gcd(candidate, maxSupply) != 1 {
224 candidate++
225 if candidate >= maxSupply {
226 candidate = 1
227 }
228 }
229 return candidate
230}
231
232func deriveSeedNumber(label, secret string) int64 {
233 hash := hashBytes([]byte("GUARDIANS|PERMUTATION|" + label + "|" + secret))
234 value, err := strconv.ParseInt(hash[:15], 16, 64)
235 if err != nil {
236 panic(err)
237 }
238 return value
239}
240
241func gcd(first, second int64) int64 {
242 for second != 0 {
243 first, second = second, first%second
244 }
245 return first
246}
247
248func normalizeHash(value string) string {
249 decoded, err := hex.DecodeString(value)
250 if err != nil || len(decoded) != sha256.Size {
251 return ""
252 }
253 return hex.EncodeToString(decoded)
254}