metadata.gno
5.95 Kb · 166 lines
1package nft
2
3import (
4 "chain"
5 "crypto/sha256"
6 "encoding/hex"
7 "strconv"
8 "strings"
9
10 "gno.land/p/samcrew/launchpad/meta/v1"
11)
12
13const (
14 MetadataStatic = "static" // base URI fixed at creation
15 MetadataReveal = "reveal" // placeholder until one committed reveal
16 MetadataMutable = "mutable" // creator may change the base URI until frozen
17)
18
19// metadata decides what TokenURI answers. Once frozen, the base URI can never
20// change again; only then can traits be committed.
21type metadata struct {
22 mode string
23 baseURI string // empty until a reveal
24 placeholderURI string // reveal: served for every token until Reveal
25 commitment string // reveal: RevealCommitment of the base URI to come
26 committer address // reveal: the creator who made the commitment
27 provenance string // reveal: SHA-256 of the ordered metadata manifest
28 frozen bool
29 revision int64 // base URI changes since creation
30}
31
32// contentAddressed accepts an IPFS path that resolves to the same files for
33// good: a server behind any other scheme could change them at will, and an
34// empty CID, a "." or ".." segment, an escape, a query or a fragment could
35// make a gateway resolve it elsewhere, a mutable IPNS name included.
36func contentAddressed(s string) bool {
37 return strings.HasPrefix(s, "ipfs://") && len(s) > 7 && s[7] != '/' &&
38 !strings.Contains(s, "/.") && !strings.ContainsAny(s, "%?#") && meta.Image(s) == nil
39}
40
41// A base URI is a directory: token n resolves to <base><n>.json.
42func validBaseURI(s string) bool {
43 return contentAddressed(s) && s[len(s)-1] == '/'
44}
45
46func validHash(s string) bool {
47 if len(s) != 64 {
48 return false
49 }
50 for i := 0; i < len(s); i++ {
51 if c := s[i]; !(c >= '0' && c <= '9') && !(c >= 'a' && c <= 'f') {
52 return false
53 }
54 }
55 return true
56}
57
58// RevealCommitment is what a creator commits to at creation and opens at
59// Reveal: sha256("launchpad-reveal-v1|<creator>|<salt>|<base URI>"), with a
60// salt of 64 hex digits the creator keeps secret until then. The salt keeps a
61// guessed URI from being confirmed before the reveal. The creator's address
62// keeps another account from reusing the commitment: a collection's ID does
63// not exist yet when its creator commits, so the account stands in for it.
64func RevealCommitment(creator address, salt, baseURI string) string {
65 h := sha256.Sum256([]byte("launchpad-reveal-v1|" + creator.String() + "|" + salt + "|" + baseURI))
66 return hex.EncodeToString(h[:])
67}
68
69func newMetadata(mode, baseURI, placeholderURI, commitment, provenance string, creator address) metadata {
70 ok := false
71 switch mode {
72 case MetadataStatic, MetadataMutable:
73 ok = validBaseURI(baseURI) && placeholderURI == "" && commitment == "" && provenance == ""
74 case MetadataReveal:
75 // What is hidden must be content-addressed, or the commitment would
76 // say nothing about the files behind it.
77 ok = baseURI == "" && contentAddressed(placeholderURI) &&
78 strings.HasSuffix(placeholderURI, ".json") &&
79 validHash(commitment) && commitment != strings.Repeat("0", 64) &&
80 validHash(provenance)
81 }
82 if !ok {
83 panic("launchpad/nft: invalid metadata terms")
84 }
85 m := metadata{mode: mode, baseURI: baseURI, placeholderURI: placeholderURI,
86 commitment: commitment, provenance: provenance, frozen: mode == MetadataStatic}
87 if mode == MetadataReveal {
88 m.committer = creator
89 }
90 return m
91}
92
93func (m metadata) tokenURI(number int64) string {
94 if m.baseURI == "" {
95 return m.placeholderURI
96 }
97 return m.baseURI + strconv.FormatInt(number, 10) + ".json"
98}
99
100func emitMetadata(id string, m metadata) {
101 chain.Emit("MetadataChanged", "collection", id, "base_uri", m.baseURI,
102 "revision", strconv.FormatInt(m.revision, 10),
103 "frozen", strconv.FormatBool(m.frozen))
104}
105
106// Reveal publishes the base URI committed at creation, with the salt that
107// opens the commitment, and fixes it for good. The commitment stays bound to
108// the account that created the collection, whoever holds the creator role now.
109func Reveal(cur realm, id, baseURI, salt string) {
110 m := &creatorOf(cur, id).metadata
111 if m.mode != MetadataReveal || m.frozen {
112 panic("launchpad/nft: nothing to reveal")
113 }
114 if !validBaseURI(baseURI) || !validHash(salt) ||
115 RevealCommitment(m.committer, salt, baseURI) != m.commitment {
116 panic("launchpad/nft: reveal does not match the commitment")
117 }
118 m.baseURI, m.frozen, m.revision = baseURI, true, 1
119 emitMetadata(id, *m)
120}
121
122func mutable(cur realm, id string) *metadata {
123 m := &creatorOf(cur, id).metadata
124 if m.mode != MetadataMutable || m.frozen {
125 panic("launchpad/nft: metadata is fixed")
126 }
127 return m
128}
129
130// SetBaseURI points a mutable collection at new metadata. Every change is
131// announced with its revision number.
132func SetBaseURI(cur realm, id, baseURI string) {
133 m := mutable(cur, id)
134 if !validBaseURI(baseURI) || baseURI == m.baseURI {
135 panic("launchpad/nft: invalid base URI")
136 }
137 m.baseURI = baseURI
138 m.revision++
139 emitMetadata(id, *m)
140}
141
142// FreezeMetadata gives up the right to change a mutable collection's base URI.
143func FreezeMetadata(cur realm, id string) {
144 m := mutable(cur, id)
145 m.frozen = true
146 emitMetadata(id, *m)
147}
148
149// CommitTraits records, once, the Merkle root of the collection's traits. It
150// waits for frozen metadata so the root cannot outlive the files it describes.
151// A leaf is sha256(0x00 || "launchpad-trait-v1|<collection>|<number>|<type>=<value>")
152// in a p/samcrew/launchpad/merkle/v1 tree, where neither the type nor the
153// value contains "=" or "|": a leaf has then one reading only, and a leaf
154// that breaks the rule matches no trait offer. The ledger cannot check the
155// root against the files; whoever relies on it must recompute it from them.
156func CommitTraits(cur realm, id, root string) {
157 c := creatorOf(cur, id)
158 if !c.metadata.frozen || c.traitsRoot != "" || !validHash(root) {
159 panic("launchpad/nft: traits cannot be committed")
160 }
161 c.traitsRoot = root
162 chain.Emit("TraitsCommitted", "collection", id, "root", root)
163}
164
165// TraitsRoot is empty until the creator commits one.
166func TraitsRoot(id string) string { return mustCollection(id).traitsRoot }