tokens.gno
13.05 Kb · 403 lines
1// Package tokens creates registered Launchpad GRC20 ledgers for the issuer
2// realms listed in config. A token belongs to the issuer that created it: no
3// other realm, and no admin, can mint into it.
4package tokens
5
6import (
7 "chain"
8 "strconv"
9
10 "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)
19
20const (
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)
26
27// Metadata is editable only by the original creator until frozen.
28type Metadata struct {
29 Description string
30 Image string
31 Website string
32 XHandle string
33 Telegram string
34}
35
36// createSpec is assembled within this realm: a foreign Gno realm cannot
37// allocate a struct declared here, so the crossing interface uses scalars.
38type createSpec struct {
39 Creator address
40 Mode string
41 Name string
42 Ticker string
43 Decimals int
44 InitialSupply int64
45 MaxSupply int64
46 CurrencyKey string
47 ExpectedVersion int64
48 Metadata Metadata
49}
50
51// Info excludes the private ledger capability and can be returned safely.
52type Info struct {
53 ID string
54 RegistryKey string
55 GRC20ID string
56 Issuer address
57 Creator address
58 Mode string
59 Name string
60 Ticker string
61 Decimals int
62 InitialSupply int64
63 MaxSupply int64
64 ConfigVersion int64
65 CurrencyKey string
66 Metadata Metadata
67 MetadataFrozen bool
68 MintAuthority address
69 PendingMintAuth address
70 MintRenounced bool
71}
72
73type entry struct {
74 info Info
75 token *grc20.Token
76 ledger *grc20.PrivateLedger
77}
78
79// Tokens live in an AVL tree: creating one touches its own entry and the path
80// 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 creation
82// order and no token is ever removed, so a page of the list is a range of ids.
83var (
84 nextID seqid.ID
85 entries = avl.NewTree() // token id -> *entry
86)
87
88// requireIssuer returns the immediate caller, which config must list as a
89// token issuer. Only a realm's own address is ever listed, so an account or a
90// 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 issuer
100}
101
102func check(err error) {
103 if err != nil {
104 panic(err.Error())
105 }
106}
107
108// caller is the immediate user or realm. Coins a user attaches to a direct
109// 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}
117
118// The ledger keys balances and allowances by the address text, so an address
119// in any spelling but the one the chain gives a caller would hold something
120// no caller can reach.
121func requireAddress(a address) {
122 if !meta.ValidAddress(a) {
123 panic("launchpad/tokens: invalid address")
124 }
125}
126
127func 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}
134
135// The founding names remain unavailable even if governance globally clears a
136// 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 true
141 }
142 return false
143}
144
145func 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}
172
173func laneForMode(mode string) string {
174 switch mode {
175 case ModeDirectFixed, ModeDirectCap:
176 return config.LaneDirect
177 case ModeFairSale:
178 return config.LaneFairSale
179 }
180 panic("launchpad/tokens: invalid mode")
181}
182
183func 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), idNumber
189}
190
191// Create registers exactly one new GRC20 for a listed issuer, which must
192// authenticate the creator, reserve the launch terms and collect payment
193// before this crossing call. The currency key names the config action
194// currency: creation-fee currency for a direct token, quote currency for a
195// fair sale; the issuer binds it to the corresponding payment route. The
196// 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 allocation
198// 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.Creator
230 }
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 id
237}
238
239func 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 e
245}
246
247func GetInfo(id string) Info { return mustEntry(id).info }
248
249// Count is the number of tokens created: ids are T1 to T<Count>, none removed.
250func Count() int64 { return int64(nextID) }
251
252func RegistryKeyOf(id string) string { return mustEntry(id).info.RegistryKey }
253
254func BalanceOf(id string, owner address) int64 {
255 requireAddress(owner)
256 return mustEntry(id).token.BalanceOf(owner)
257}
258
259func TotalSupply(id string) int64 { return mustEntry(id).token.TotalSupply() }
260
261// MintCapped can only be reached through the issuer that created the token.
262// That issuer authenticates the live caller as the current mint authority
263// and must partition the newly minted balance into recorded allocations in
264// this same transaction. The ledger independently rechecks the authority and
265// the immutable cap, and takes no mint while the direct lane is closed: the
266// 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}
280
281func Allowance(id string, owner, spender address) int64 {
282 requireAddress(owner)
283 requireAddress(spender)
284 return mustEntry(id).token.Allowance(owner, spender)
285}
286
287// Standard wrappers operate only on the immediate caller's balance or
288// allowance, accept no coins, and take an address only as the chain writes
289// 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 the
291// receiver. A script an account runs is such a realm with the account's
292// address, so these wrappers are an account's only way in only while the
293// 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}
299
300func 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}
305
306func 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}
312
313func 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}
320
321func 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 = m
333 chain.Emit("TokenMetadataChanged", "id", id, "creator", e.info.Creator.String())
334}
335
336func 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 = true
343 chain.Emit("TokenMetadataFrozen", "id", id, "creator", e.info.Creator.String())
344}
345
346// 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 = next
357 chain.Emit("MintAuthorityProposed", "id", id, "next", next.String())
358}
359
360func 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}
370
371func 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 = who
379 e.info.PendingMintAuth = ""
380 chain.Emit("MintAuthorityAccepted", "id", id, "authority", who.String())
381}
382
383func 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 = true
393 chain.Emit("MintAuthorityRenounced", "id", id)
394}
395
396func 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}