1// Package tokens creates registered Launchpad GRC20 ledgers for the issuer2// realms listed in config. A token belongs to the issuer that created it: no3// other realm, and no admin, can mint into it.4package tokens56import (7 "chain"8 "strconv"910 "gno.land/p/nt/avl/v0"11 "gno.land/p/nt/grc20/v0"12 "gno.land/p/nt/seqid/v0"13 "gno.land/p/samcrew/launchpad/currency/v1"14 "gno.land/p/samcrew/launchpad/meta/v1"15 sm "gno.land/p/samcrew/launchpad/safemath/v1"16 "gno.land/r/nt/grc20reg/v0"17 "gno.land/r/samcrew/launchpad/config/v1"18)1920const (21 ModeDirectFixed = "direct_fixed"22 ModeDirectCap = "direct_capped"23 ModeFairSale = "fairsale"24 maxPublicID = 9_999_999_999 // T plus ten decimal digits: GRC20 MaxSymbolLen is 11.25)2627// Metadata is editable only by the original creator until frozen.28type Metadata struct {29 Description string30 Image string31 Website string32 XHandle string33 Telegram string34}3536// createSpec is assembled within this realm: a foreign Gno realm cannot37// allocate a struct declared here, so the crossing interface uses scalars.38type createSpec struct {39 Creator address40 Mode string41 Name string42 Ticker string43 Decimals int44 InitialSupply int6445 MaxSupply int6446 CurrencyKey string47 ExpectedVersion int6448 Metadata Metadata49}5051// Info excludes the private ledger capability and can be returned safely.52type Info struct {53 ID string54 RegistryKey string55 GRC20ID string56 Issuer address57 Creator address58 Mode string59 Name string60 Ticker string61 Decimals int62 InitialSupply int6463 MaxSupply int6464 ConfigVersion int6465 CurrencyKey string66 Metadata Metadata67 MetadataFrozen bool68 MintAuthority address69 PendingMintAuth address70 MintRenounced bool71}7273type entry struct {74 info Info75 token *grc20.Token76 ledger *grc20.PrivateLedger77}7879// Tokens live in an AVL tree: creating one touches its own entry and the path80// to it, whatever the number of tokens. A Go map, or a slice of their ids,81// would be rewritten whole on every creation. Ids are T1, T2, ... in creation82// order and no token is ever removed, so a page of the list is a range of ids.83var (84 nextID seqid.ID85 entries = avl.NewTree() // token id -> *entry86)8788// requireIssuer returns the immediate caller, which config must list as a89// token issuer. Only a realm's own address is ever listed, so an account or a90// script never passes.91func requireIssuer(cur realm) address {92 if !cur.IsCurrent() {93 panic("launchpad/tokens: stale realm context")94 }95 issuer := cur.Previous().Address()96 if !config.IsIssuer(config.LedgerTokens, issuer) {97 panic("launchpad/tokens: token issuer only")98 }99 return issuer100}101102func check(err error) {103 if err != nil {104 panic(err.Error())105 }106}107108// caller is the immediate user or realm. Coins a user attaches to a direct109// call would land in this realm, which has no way to return them.110func caller(cur realm) address {111 if !cur.IsCurrent() {112 panic("launchpad/tokens: stale realm context")113 }114 currency.AssertNoCoins(0, cur)115 return cur.Previous().Address()116}117118// The ledger keys balances and allowances by the address text, so an address119// in any spelling but the one the chain gives a caller would hold something120// no caller can reach.121func requireAddress(a address) {122 if !meta.ValidAddress(a) {123 panic("launchpad/tokens: invalid address")124 }125}126127func validateMetadata(m Metadata) {128 check(meta.Description(m.Description))129 check(meta.Image(m.Image))130 check(meta.Website(m.Website))131 check(meta.Handle(m.XHandle))132 check(meta.Handle(m.Telegram))133}134135// The founding names remain unavailable even if governance globally clears a136// config reservation. A creator-specific grant needs its own reviewed proof.137func protectedTicker(ticker string) bool {138 switch ticker {139 case "MEMBA", "GNOT", "UGNOT", "WUGNOT", "GNS", "USDC", "USDT", "ATOM", "BTC", "ETH", "SAMCREW":140 return true141 }142 return false143}144145func validateSpec(s createSpec) {146 requireAddress(s.Creator)147 check(meta.Name(s.Name))148 check(meta.Ticker(s.Ticker))149 validateMetadata(s.Metadata)150 if s.Decimals < 0 || s.Decimals > grc20.MaxDecimals ||151 s.InitialSupply <= 0 || s.MaxSupply < s.InitialSupply ||152 protectedTicker(s.Ticker) || config.IsReserved(s.Ticker) {153 panic("launchpad/tokens: invalid launch terms")154 }155 switch s.Mode {156 case ModeFairSale:157 if s.Decimals > 12 || s.MaxSupply != s.InitialSupply {158 panic("launchpad/tokens: invalid fair-sale terms")159 }160 case ModeDirectFixed:161 if s.Decimals > 12 || s.MaxSupply != s.InitialSupply {162 panic("launchpad/tokens: invalid direct terms")163 }164 case ModeDirectCap:165 if s.Decimals > 12 || s.MaxSupply <= s.InitialSupply {166 panic("launchpad/tokens: invalid capped terms")167 }168 default:169 panic("launchpad/tokens: invalid mode")170 }171}172173func laneForMode(mode string) string {174 switch mode {175 case ModeDirectFixed, ModeDirectCap:176 return config.LaneDirect177 case ModeFairSale:178 return config.LaneFairSale179 }180 panic("launchpad/tokens: invalid mode")181}182183func nextPublicID() (string, seqid.ID) {184 if uint64(nextID) >= maxPublicID {185 panic("launchpad/tokens: token ID exhausted")186 }187 idNumber := nextID.Next()188 return "T" + strconv.FormatUint(uint64(idNumber), 10), idNumber189}190191// Create registers exactly one new GRC20 for a listed issuer, which must192// authenticate the creator, reserve the launch terms and collect payment193// before this crossing call. The currency key names the config action194// currency: creation-fee currency for a direct token, quote currency for a195// fair sale; the issuer binds it to the corresponding payment route. The196// public symbol T<n> is distinct from the nonunique creator-selected ticker.197// The whole initial mint lands in the issuer's custody for atomic allocation198// and escrow setup, and the token stays bound to that issuer.199func Create(cur realm, creator address, mode, name, ticker string,200 decimals int, initialSupply, maxSupply int64,201 currencyKey string, expectedVersion int64,202 description, image, website, xHandle, telegram string) string {203 issuer := requireIssuer(cur)204 s := createSpec{205 Creator: creator, Mode: mode, Name: name, Ticker: ticker,206 Decimals: decimals, InitialSupply: initialSupply, MaxSupply: maxSupply,207 CurrencyKey: currencyKey,208 ExpectedVersion: expectedVersion,209 Metadata: Metadata{210 Description: description, Image: image, Website: website,211 XHandle: xHandle, Telegram: telegram,212 },213 }214 validateSpec(s)215 config.AssertNewAction(laneForMode(s.Mode), s.CurrencyKey, s.ExpectedVersion)216 id, idNumber := nextPublicID()217 token, ledger := grc20.NewToken(s.Name, id, s.Decimals, idNumber, cur)218 check(ledger.Mint(issuer, s.InitialSupply))219 registryKey := grc20reg.Register(cross(cur), token, id)220 info := Info{221 ID: id, RegistryKey: registryKey, GRC20ID: token.ID(),222 Issuer: issuer, Creator: s.Creator,223 Mode: s.Mode, Name: s.Name, Ticker: s.Ticker, Decimals: s.Decimals,224 InitialSupply: s.InitialSupply, MaxSupply: s.MaxSupply,225 ConfigVersion: s.ExpectedVersion, CurrencyKey: s.CurrencyKey,226 Metadata: s.Metadata,227 }228 if s.Mode == ModeDirectCap {229 info.MintAuthority = s.Creator230 }231 entries.Set(id, &entry{info: info, token: token, ledger: ledger})232 chain.Emit("LaunchpadTokenCreated", "id", id, "registry_key", registryKey,233 "grc20_id", token.ID(), "issuer", issuer.String(),234 "creator", s.Creator.String(),235 "mode", s.Mode, "ticker", s.Ticker, "currency_key", s.CurrencyKey)236 return id237}238239func mustEntry(id string) *entry {240 e, ok := entries.Get(id).(*entry)241 if !ok || e.info.ID != id {242 panic("launchpad/tokens: unknown token")243 }244 return e245}246247func GetInfo(id string) Info { return mustEntry(id).info }248249// Count is the number of tokens created: ids are T1 to T<Count>, none removed.250func Count() int64 { return int64(nextID) }251252func RegistryKeyOf(id string) string { return mustEntry(id).info.RegistryKey }253254func BalanceOf(id string, owner address) int64 {255 requireAddress(owner)256 return mustEntry(id).token.BalanceOf(owner)257}258259func TotalSupply(id string) int64 { return mustEntry(id).token.TotalSupply() }260261// MintCapped can only be reached through the issuer that created the token.262// That issuer authenticates the live caller as the current mint authority263// and must partition the newly minted balance into recorded allocations in264// this same transaction. The ledger independently rechecks the authority and265// the immutable cap, and takes no mint while the direct lane is closed: the266// pauser stops every issuer here, whatever each one checks by itself.267func MintCapped(cur realm, id string, authority address, amount int64) {268 issuer := requireIssuer(cur)269 e := mustEntry(id)270 config.AssertLaneOpen(config.LaneDirect, e.info.CurrencyKey)271 if e.info.Issuer != issuer || e.info.Mode != ModeDirectCap || e.info.MintRenounced ||272 e.info.MintAuthority != authority || amount <= 0 ||273 sm.Add(e.token.TotalSupply(), amount) > e.info.MaxSupply {274 panic("launchpad/tokens: capped mint denied")275 }276 check(e.ledger.Mint(issuer, amount))277 chain.Emit("LaunchpadTokenCappedMinted", "id", id,278 "authority", authority.String(), "amount", strconv.FormatInt(amount, 10))279}280281func Allowance(id string, owner, spender address) int64 {282 requireAddress(owner)283 requireAddress(spender)284 return mustEntry(id).token.Allowance(owner, spender)285}286287// Standard wrappers operate only on the immediate caller's balance or288// allowance, accept no coins, and take an address only as the chain writes289// one. A realm can also move its own balance, or an allowance it holds,290// through the token in the registry, where the GRC20 library alone checks the291// receiver. A script an account runs is such a realm with the account's292// address, so these wrappers are an account's only way in only while the293// chain does not let that account submit scripts.294func Transfer(cur realm, id string, to address, amount int64) {295 caller(cur)296 requireAddress(to)297 check(mustEntry(id).ledger.CallerTeller().Transfer(0, cur, to, amount))298}299300func Approve(cur realm, id string, spender address, amount int64) {301 caller(cur)302 requireAddress(spender)303 check(mustEntry(id).ledger.CallerTeller().Approve(0, cur, spender, amount))304}305306func TransferFrom(cur realm, id string, from, to address, amount int64) {307 caller(cur)308 requireAddress(from)309 requireAddress(to)310 check(mustEntry(id).ledger.CallerTeller().TransferFrom(0, cur, from, to, amount))311}312313func Burn(cur realm, id string, amount int64) {314 who := caller(cur)315 if amount <= 0 {316 panic("launchpad/tokens: invalid burn")317 }318 check(mustEntry(id).ledger.Burn(who, amount))319}320321func SetMetadata(cur realm, id, description, image, website, xHandle, telegram string) {322 who := caller(cur)323 e := mustEntry(id)324 if e.info.MetadataFrozen || who != e.info.Creator {325 panic("launchpad/tokens: creator metadata only")326 }327 m := Metadata{328 Description: description, Image: image, Website: website,329 XHandle: xHandle, Telegram: telegram,330 }331 validateMetadata(m)332 e.info.Metadata = m333 chain.Emit("TokenMetadataChanged", "id", id, "creator", e.info.Creator.String())334}335336func FreezeMetadata(cur realm, id string) {337 who := caller(cur)338 e := mustEntry(id)339 if e.info.MetadataFrozen || who != e.info.Creator {340 panic("launchpad/tokens: creator metadata only")341 }342 e.info.MetadataFrozen = true343 chain.Emit("TokenMetadataFrozen", "id", id, "creator", e.info.Creator.String())344}345346// A capped token's authority moves only through its own two-step handoff.347// The issuer checks this live authority before each later mint.348func ProposeMintAuthority(cur realm, id string, next address) {349 who := caller(cur)350 e := mustEntry(id)351 if e.info.Mode != ModeDirectCap || e.info.MintRenounced ||352 who != e.info.MintAuthority ||353 !meta.ValidAddress(next) || next == e.info.MintAuthority {354 panic("launchpad/tokens: mint authority only")355 }356 e.info.PendingMintAuth = next357 chain.Emit("MintAuthorityProposed", "id", id, "next", next.String())358}359360func CancelMintAuthority(cur realm, id string) {361 who := caller(cur)362 e := mustEntry(id)363 if e.info.Mode != ModeDirectCap || e.info.MintRenounced ||364 who != e.info.MintAuthority || e.info.PendingMintAuth == "" {365 panic("launchpad/tokens: no mint authority proposal")366 }367 e.info.PendingMintAuth = ""368 chain.Emit("MintAuthorityCancelled", "id", id)369}370371func AcceptMintAuthority(cur realm, id string) {372 who := caller(cur)373 e := mustEntry(id)374 if e.info.Mode != ModeDirectCap || e.info.MintRenounced ||375 e.info.PendingMintAuth == "" || who != e.info.PendingMintAuth {376 panic("launchpad/tokens: pending mint authority only")377 }378 e.info.MintAuthority = who379 e.info.PendingMintAuth = ""380 chain.Emit("MintAuthorityAccepted", "id", id, "authority", who.String())381}382383func RenounceMintAuthority(cur realm, id string) {384 who := caller(cur)385 e := mustEntry(id)386 if e.info.Mode != ModeDirectCap || e.info.MintRenounced ||387 who != e.info.MintAuthority {388 panic("launchpad/tokens: mint authority only")389 }390 e.info.MintAuthority = ""391 e.info.PendingMintAuth = ""392 e.info.MintRenounced = true393 chain.Emit("MintAuthorityRenounced", "id", id)394}395396func Render(path string) string {397 if path == "" {398 return "# Launchpad tokens\n\nRegistered GRC20 ledgers created by the issuers listed in config.\n"399 }400 e := mustEntry(path)401 return "# " + meta.EscapeMarkdown(e.info.Name) + "\n\nTicker: " +402 meta.EscapeMarkdown(e.info.Ticker) + "\n"403}404AcceptMintAuthority(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, id string)
Allowance(id string, owner string, spender string) int64
Approve(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, id string, spender string, amount int64)
BalanceOf(id string, owner string) int64
Burn(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, id string, amount int64)
CancelMintAuthority(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, id string)
Count() int64
Create(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, creator string, mode string, name string, ticker string, decimals int, initialSupply int64, maxSupply int64, currencyKey string, expectedVersion int64, description string, image string, website string, xHandle string, telegram string) string
FreezeMetadata(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, id string)
GetInfo(id string) struct{ID string; RegistryKey string; GRC20ID string; Issuer .uverse.address; Creator .uverse.address; Mode string; Name string; Ticker string; Decimals int; InitialSupply int64; MaxSupply int64; ConfigVersion int64; CurrencyKey string; Metadata gno.land/r/samcrew/launchpad/tokens/v1.Metadata; MetadataFrozen bool; MintAuthority .uverse.address; PendingMintAuth .uverse.address; MintRenounced bool}
ListTokensJSON(page int64, size int64) string
MintCapped(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, id string, authority string, amount int64)
ProposeMintAuthority(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, id string, next string)
RegistryKeyOf(id string) string
Render(path string) string
RenounceMintAuthority(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, id string)
SetMetadata(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, id string, description string, image string, website string, xHandle string, telegram string)
TokenJSON(id string) string
TotalSupply(id string) int64
Transfer(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, id string, to string, amount int64)
TransferFrom(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, id string, from string, to string, amount int64)
Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.
vm/qrender output, sanitized (docs/render-security.md) and displayed in an empty-sandbox iframe — scripts, forms and popups cannot run. Links stay inert in-preview; right-click to open.