package nft
import (
"chain"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"gno.land/p/samcrew/launchpad/meta/v1"
)
const (
MetadataStatic = "static" // base URI fixed at creation
MetadataReveal = "reveal" // placeholder until one committed reveal
MetadataMutable = "mutable" // creator may change the base URI until frozen
)
// metadata decides what TokenURI answers. Once frozen, the base URI can never
// change again; only then can traits be committed.
type metadata struct {
mode string
baseURI string // empty until a reveal
placeholderURI string // reveal: served for every token until Reveal
commitment string // reveal: RevealCommitment of the base URI to come
committer address // reveal: the creator who made the commitment
provenance string // reveal: SHA-256 of the ordered metadata manifest
frozen bool
revision int64 // base URI changes since creation
}
// contentAddressed accepts an IPFS path that resolves to the same files for
// good: a server behind any other scheme could change them at will, and an
// empty CID, a "." or ".." segment, an escape, a query or a fragment could
// make a gateway resolve it elsewhere, a mutable IPNS name included.
func contentAddressed(s string) bool {
return strings.HasPrefix(s, "ipfs://") && len(s) > 7 && s[7] != '/' &&
!strings.Contains(s, "/.") && !strings.ContainsAny(s, "%?#") && meta.Image(s) == nil
}
// A base URI is a directory: token n resolves to .json.
func validBaseURI(s string) bool {
return contentAddressed(s) && s[len(s)-1] == '/'
}
func validHash(s string) bool {
if len(s) != 64 {
return false
}
for i := 0; i < len(s); i++ {
if c := s[i]; !(c >= '0' && c <= '9') && !(c >= 'a' && c <= 'f') {
return false
}
}
return true
}
// RevealCommitment is what a creator commits to at creation and opens at
// Reveal: sha256("launchpad-reveal-v1|||"), with a
// salt of 64 hex digits the creator keeps secret until then. The salt keeps a
// guessed URI from being confirmed before the reveal. The creator's address
// keeps another account from reusing the commitment: a collection's ID does
// not exist yet when its creator commits, so the account stands in for it.
func RevealCommitment(creator address, salt, baseURI string) string {
h := sha256.Sum256([]byte("launchpad-reveal-v1|" + creator.String() + "|" + salt + "|" + baseURI))
return hex.EncodeToString(h[:])
}
func newMetadata(mode, baseURI, placeholderURI, commitment, provenance string, creator address) metadata {
ok := false
switch mode {
case MetadataStatic, MetadataMutable:
ok = validBaseURI(baseURI) && placeholderURI == "" && commitment == "" && provenance == ""
case MetadataReveal:
// What is hidden must be content-addressed, or the commitment would
// say nothing about the files behind it.
ok = baseURI == "" && contentAddressed(placeholderURI) &&
strings.HasSuffix(placeholderURI, ".json") &&
validHash(commitment) && commitment != strings.Repeat("0", 64) &&
validHash(provenance)
}
if !ok {
panic("launchpad/nft: invalid metadata terms")
}
m := metadata{mode: mode, baseURI: baseURI, placeholderURI: placeholderURI,
commitment: commitment, provenance: provenance, frozen: mode == MetadataStatic}
if mode == MetadataReveal {
m.committer = creator
}
return m
}
func (m metadata) tokenURI(number int64) string {
if m.baseURI == "" {
return m.placeholderURI
}
return m.baseURI + strconv.FormatInt(number, 10) + ".json"
}
func emitMetadata(id string, m metadata) {
chain.Emit("MetadataChanged", "collection", id, "base_uri", m.baseURI,
"revision", strconv.FormatInt(m.revision, 10),
"frozen", strconv.FormatBool(m.frozen))
}
// Reveal publishes the base URI committed at creation, with the salt that
// opens the commitment, and fixes it for good. The commitment stays bound to
// the account that created the collection, whoever holds the creator role now.
func Reveal(cur realm, id, baseURI, salt string) {
m := &creatorOf(cur, id).metadata
if m.mode != MetadataReveal || m.frozen {
panic("launchpad/nft: nothing to reveal")
}
if !validBaseURI(baseURI) || !validHash(salt) ||
RevealCommitment(m.committer, salt, baseURI) != m.commitment {
panic("launchpad/nft: reveal does not match the commitment")
}
m.baseURI, m.frozen, m.revision = baseURI, true, 1
emitMetadata(id, *m)
}
func mutable(cur realm, id string) *metadata {
m := &creatorOf(cur, id).metadata
if m.mode != MetadataMutable || m.frozen {
panic("launchpad/nft: metadata is fixed")
}
return m
}
// SetBaseURI points a mutable collection at new metadata. Every change is
// announced with its revision number.
func SetBaseURI(cur realm, id, baseURI string) {
m := mutable(cur, id)
if !validBaseURI(baseURI) || baseURI == m.baseURI {
panic("launchpad/nft: invalid base URI")
}
m.baseURI = baseURI
m.revision++
emitMetadata(id, *m)
}
// FreezeMetadata gives up the right to change a mutable collection's base URI.
func FreezeMetadata(cur realm, id string) {
m := mutable(cur, id)
m.frozen = true
emitMetadata(id, *m)
}
// CommitTraits records, once, the Merkle root of the collection's traits. It
// waits for frozen metadata so the root cannot outlive the files it describes.
// A leaf is sha256(0x00 || "launchpad-trait-v1|||=")
// in a p/samcrew/launchpad/merkle/v1 tree, where neither the type nor the
// value contains "=" or "|": a leaf has then one reading only, and a leaf
// that breaks the rule matches no trait offer. The ledger cannot check the
// root against the files; whoever relies on it must recompute it from them.
func CommitTraits(cur realm, id, root string) {
c := creatorOf(cur, id)
if !c.metadata.frozen || c.traitsRoot != "" || !validHash(root) {
panic("launchpad/nft: traits cannot be committed")
}
c.traitsRoot = root
chain.Emit("TraitsCommitted", "collection", id, "root", root)
}
// TraitsRoot is empty until the creator commits one.
func TraitsRoot(id string) string { return mustCollection(id).traitsRoot }