Search Apps Documentation Source Content File Folder Download Copy Actions Download

tokenfactory.gno

13.22 Kb · 457 lines
  1// Package tokenfactory — Samouraï Coop Token Factory
  2//
  3// A curated GRC20 token factory with on-chain platform fees, event emission,
  4// input validation, and faucet rate limiting.
  5//
  6// Forked from gno.land/r/demo/defi/grc20factory with improvements.
  7// Uses the standard gno.land/p/demo/tokens/grc20 package and registers
  8// tokens with gno.land/r/demo/defi/grc20reg for full ecosystem compatibility.
  9//
 10// Improvements over upstream:
 11//   - On-chain 2.5% platform fee on Mint and initial supply
 12//   - chain.Emit events for all state-changing operations
 13//   - Symbol validation (1-10 uppercase alphanumeric)
 14//   - Faucet rate limiting (~24h cooldown per address)
 15//   - Optional token metadata (description, website, IPFS logo)
 16//
 17// All original function signatures are preserved for 100% API compatibility.
 18package tokenfactory
 19
 20import (
 21	"chain"
 22	"chain/runtime"
 23
 24	"gno.land/p/demo/tokens/grc20"
 25	"gno.land/p/nt/avl/v0"
 26	p "gno.land/p/nt/avl/v0/pager"
 27	"gno.land/p/nt/avl/v0/rotree"
 28	"gno.land/p/nt/mux/v0"
 29	"gno.land/p/nt/ownable/v0"
 30	"gno.land/p/nt/ufmt/v0"
 31	"gno.land/r/demo/defi/grc20reg"
 32)
 33
 34// ── Fee Configuration ────────────────────────────────────────
 35
 36// Platform fee: 2.5% (25/1000). Applied to Mint and initial supply.
 37// Fee goes to Samouraï Coop multisig for development & maintenance.
 38var (
 39	feeRate      int64   = 25 // 25/1000 = 2.5%
 40	feeRecipient address = "g1pavqfezrge9kgkrkrahqm982yhw5j45v0zw27v"
 41)
 42
 43// ── State ────────────────────────────────────────────────────
 44
 45var (
 46	instances        avl.Tree // symbol → *instance
 47	pager            = p.NewPager(rotree.Wrap(&instances, nil), 20, false)
 48	faucetCooldowns  avl.Tree // "SYMBOL:addr" → int64 (block height)
 49	tokenMetas       avl.Tree // symbol → *TokenMeta
 50)
 51
 52// Faucet cooldown: ~24h at ~2min blocks = 720 blocks.
 53const faucetCooldownBlocks int64 = 720
 54
 55type instance struct {
 56	token  *grc20.Token
 57	ledger *grc20.PrivateLedger
 58	admin  *ownable.Ownable
 59	faucet int64 // per-request amount. disabled if 0.
 60}
 61
 62// TokenMeta holds optional metadata for a token.
 63type TokenMeta struct {
 64	Description string
 65	Website     string
 66	LogoIPFS    string // "ipfs://Qm..."
 67}
 68
 69// ── Event Constants ──────────────────────────────────────────
 70
 71const (
 72	EventTokenCreated = "TokenCreated"
 73	EventFaucetClaim  = "FaucetClaim"
 74	EventMetaUpdated  = "MetaUpdated"
 75)
 76
 77// ── Token Creation ───────────────────────────────────────────
 78
 79// New creates a new GRC20 token with the caller as admin.
 80// If initialMint > 0, a 2.5% platform fee is applied.
 81func New(cur realm, name, symbol string, decimals int, initialMint, faucet int64) {
 82	caller := cur.Previous().Address()
 83	NewWithAdmin(cur, name, symbol, decimals, initialMint, faucet, caller)
 84}
 85
 86// NewWithAdmin creates a new GRC20 token with a specified admin address.
 87// If initialMint > 0, a 2.5% platform fee is applied.
 88func NewWithAdmin(cur realm, name, symbol string, decimals int, initialMint, faucet int64, admin address) {
 89	// ── Validation ──
 90	validateSymbol(symbol)
 91	validateName(name)
 92	validateDecimals(decimals)
 93
 94	exists := instances.Has(symbol)
 95	if exists {
 96		panic("token already exists")
 97	}
 98
 99	token, ledger := grc20.NewToken(0, cur, name, symbol, decimals)
100
101	if initialMint > 0 {
102		fee := applyFee(ledger, admin, initialMint)
103		chain.Emit(
104			grc20.MintEvent,
105			"symbol", symbol,
106			"to", string(admin),
107			"amount", ufmt.Sprintf("%d", initialMint),
108			"fee", ufmt.Sprintf("%d", fee),
109		)
110	}
111
112	inst := instance{
113		token:  token,
114		ledger: ledger,
115		admin:  ownable.NewWithAddress(admin),
116		faucet: faucet,
117	}
118	instances.Set(symbol, &inst)
119
120	// Register with ecosystem registry for GnoSwap/Gnoscan discoverability
121	grc20reg.Register(cross(cur), token, symbol)
122
123	chain.Emit(
124		EventTokenCreated,
125		"symbol", symbol,
126		"name", name,
127		"decimals", ufmt.Sprintf("%d", decimals),
128		"initialMint", ufmt.Sprintf("%d", initialMint),
129		"admin", string(admin),
130		"faucet", ufmt.Sprintf("%d", faucet),
131	)
132}
133
134// ── Read-Only Accessors ──────────────────────────────────────
135
136// Bank returns the standard *grc20.Token for ecosystem interop.
137func Bank(symbol string) *grc20.Token {
138	inst := mustGetInstance(symbol)
139	return inst.token
140}
141
142func TotalSupply(symbol string) int64 {
143	inst := mustGetInstance(symbol)
144	return inst.token.ReadonlyTeller().TotalSupply()
145}
146
147func HasAddr(symbol string, owner address) bool {
148	inst := mustGetInstance(symbol)
149	return inst.token.HasAddr(owner)
150}
151
152func BalanceOf(symbol string, owner address) int64 {
153	inst := mustGetInstance(symbol)
154	return inst.token.ReadonlyTeller().BalanceOf(owner)
155}
156
157func Allowance(symbol string, owner, spender address) int64 {
158	inst := mustGetInstance(symbol)
159	return inst.token.ReadonlyTeller().Allowance(owner, spender)
160}
161
162// GetFeeConfig returns the current fee rate (per-mille) and recipient address.
163func GetFeeConfig() (int64, address) {
164	return feeRate, feeRecipient
165}
166
167// GetTokenMeta returns the metadata for a token, or nil if not set.
168func GetTokenMeta(symbol string) *TokenMeta {
169	v, exists := tokenMetas.Get(symbol)
170	if !exists {
171		return nil
172	}
173	return v.(*TokenMeta)
174}
175
176// ── State-Changing Operations ────────────────────────────────
177
178func Transfer(cur realm, symbol string, to address, amount int64) {
179	inst := mustGetInstance(symbol)
180	caller := cur.Previous().Address()
181	teller := inst.ledger.ImpersonateTeller(caller)
182	checkErr(teller.Transfer(0, cur, to, amount))
183
184	chain.Emit(
185		grc20.TransferEvent,
186		"symbol", symbol,
187		"from", string(caller),
188		"to", string(to),
189		"amount", ufmt.Sprintf("%d", amount),
190	)
191}
192
193func Approve(cur realm, symbol string, spender address, amount int64) {
194	inst := mustGetInstance(symbol)
195	caller := cur.Previous().Address()
196	teller := inst.ledger.ImpersonateTeller(caller)
197	checkErr(teller.Approve(0, cur, spender, amount))
198
199	chain.Emit(
200		grc20.ApprovalEvent,
201		"symbol", symbol,
202		"owner", string(caller),
203		"spender", string(spender),
204		"amount", ufmt.Sprintf("%d", amount),
205	)
206}
207
208func TransferFrom(cur realm, symbol string, from, to address, amount int64) {
209	inst := mustGetInstance(symbol)
210	caller := cur.Previous().Address()
211	teller := inst.ledger.ImpersonateTeller(caller)
212	checkErr(teller.TransferFrom(0, cur, from, to, amount))
213
214	chain.Emit(
215		grc20.TransferEvent,
216		"symbol", symbol,
217		"from", string(from),
218		"to", string(to),
219		"amount", ufmt.Sprintf("%d", amount),
220		"spender", string(caller),
221	)
222}
223
224// Mint creates new tokens. Admin only. 2.5% platform fee applied.
225func Mint(cur realm, symbol string, to address, amount int64) {
226	inst := mustGetInstance(symbol)
227	inst.admin.AssertOwnedBy(cur.Previous().Address())
228
229	fee := applyFee(inst.ledger, to, amount)
230
231	chain.Emit(
232		grc20.MintEvent,
233		"symbol", symbol,
234		"to", string(to),
235		"amount", ufmt.Sprintf("%d", amount),
236		"fee", ufmt.Sprintf("%d", fee),
237	)
238}
239
240// Burn destroys tokens. Admin only.
241func Burn(cur realm, symbol string, from address, amount int64) {
242	inst := mustGetInstance(symbol)
243	inst.admin.AssertOwnedBy(cur.Previous().Address())
244	checkErr(inst.ledger.Burn(from, amount))
245
246	chain.Emit(
247		grc20.BurnEvent,
248		"symbol", symbol,
249		"from", string(from),
250		"amount", ufmt.Sprintf("%d", amount),
251	)
252}
253
254// Faucet mints free tokens if enabled. Rate-limited to ~1 claim per 24h per address.
255func Faucet(cur realm, symbol string) {
256	inst := mustGetInstance(symbol)
257	if inst.faucet == 0 {
258		panic("faucet disabled for this token")
259	}
260
261	caller := cur.Previous().Address()
262
263	// Rate limiting: per-address cooldown
264	key := symbol + ":" + string(caller)
265	if v, exists := faucetCooldowns.Get(key); exists {
266		lastBlock := v.(int64)
267		if runtime.ChainHeight()-lastBlock < faucetCooldownBlocks {
268			panic("faucet: cooldown active (try again in ~24h)")
269		}
270	}
271
272	checkErr(inst.ledger.Mint(caller, inst.faucet))
273	faucetCooldowns.Set(key, runtime.ChainHeight())
274
275	chain.Emit(
276		EventFaucetClaim,
277		"symbol", symbol,
278		"to", string(caller),
279		"amount", ufmt.Sprintf("%d", inst.faucet),
280		"block", ufmt.Sprintf("%d", runtime.ChainHeight()),
281	)
282}
283
284// ── Admin Functions ──────────────────────────────────────────
285
286func DropInstanceOwnership(cur realm, symbol string) {
287	inst := mustGetInstance(symbol)
288	checkErr(inst.admin.DropOwnership(0, cur))
289}
290
291func TransferInstanceOwnership(cur realm, symbol string, newOwner address) {
292	inst := mustGetInstance(symbol)
293	checkErr(inst.admin.TransferOwnership(0, cur, newOwner))
294}
295
296// SetTokenMeta sets optional metadata for a token. Admin only.
297// Lengths: description ≤ 280, website ≤ 128, logo ≤ 100 chars.
298func SetTokenMeta(cur realm, symbol, description, website, logo string) {
299	inst := mustGetInstance(symbol)
300	inst.admin.AssertOwnedBy(cur.Previous().Address())
301
302	if len(description) > 280 {
303		panic("description too long (max 280 chars)")
304	}
305	if len(website) > 128 {
306		panic("website too long (max 128 chars)")
307	}
308	if len(logo) > 100 {
309		panic("logo URI too long (max 100 chars)")
310	}
311
312	tokenMetas.Set(symbol, &TokenMeta{
313		Description: description,
314		Website:     website,
315		LogoIPFS:    logo,
316	})
317
318	chain.Emit(
319		EventMetaUpdated,
320		"symbol", symbol,
321	)
322}
323
324// ── Pagination ───────────────────────────────────────────────
325
326func ListTokens(pageNumber, pageSize int) []*grc20.Token {
327	page := pager.GetPageWithSize(pageNumber, pageSize)
328
329	tokens := make([]*grc20.Token, len(page.Items))
330	for i := range page.Items {
331		tokens[i] = page.Items[i].Value.(*instance).token
332	}
333
334	return tokens
335}
336
337// ── Render ───────────────────────────────────────────────────
338
339func Render(path string) string {
340	router := mux.NewRouter()
341	router.HandleFunc("", renderHome)
342	router.HandleFunc("{symbol}", renderToken)
343	router.HandleFunc("{symbol}/balance/{address}", renderBalance)
344	return router.Render(path)
345}
346
347func renderHome(res *mux.ResponseWriter, req *mux.Request) {
348	out := ufmt.Sprintf("# Samcrew Token Factory (%d tokens)\n\n", instances.Size())
349	out += ufmt.Sprintf("*Platform fee: %d.%d%% → `%s`*\n\n", feeRate/10, feeRate%10, string(feeRecipient))
350
351	page := pager.MustGetPageByPath(req.RawPath)
352
353	for _, item := range page.Items {
354		token := item.Value.(*instance).token
355		out += "- " + ufmt.Sprintf(
356			"[%s ($%s)](/r/samcrew/tokenfactory:%s)",
357			token.GetName(), token.GetSymbol(), token.GetSymbol(),
358		) + "\n"
359	}
360	out += "\n"
361	out += page.Picker(req.Path)
362
363	res.Write(out)
364}
365
366func renderToken(res *mux.ResponseWriter, req *mux.Request) {
367	symbol := req.GetVar("symbol")
368	inst := mustGetInstance(symbol)
369
370	out := inst.token.RenderHome()
371	out += ufmt.Sprintf("- **Admin**: %s\n", string(inst.admin.Owner()))
372
373	if inst.faucet > 0 {
374		out += ufmt.Sprintf("- **Faucet**: %d per claim (~24h cooldown)\n", inst.faucet)
375	}
376
377	// Show metadata if set
378	if meta := GetTokenMeta(symbol); meta != nil {
379		out += "\n### Metadata\n"
380		if meta.Description != "" {
381			out += ufmt.Sprintf("- **Description**: %s\n", meta.Description)
382		}
383		if meta.Website != "" {
384			out += ufmt.Sprintf("- **Website**: %s\n", meta.Website)
385		}
386		if meta.LogoIPFS != "" {
387			out += ufmt.Sprintf("- **Logo**: %s\n", meta.LogoIPFS)
388		}
389	}
390
391	res.Write(out)
392}
393
394func renderBalance(res *mux.ResponseWriter, req *mux.Request) {
395	symbol := req.GetVar("symbol")
396	addr := req.GetVar("address")
397
398	inst := mustGetInstance(symbol)
399	balance := inst.token.CallerTeller().BalanceOf(address(addr))
400
401	out := ufmt.Sprintf("%s balance: %d\n", addr, balance)
402	res.Write(out)
403}
404
405// ── Internal Helpers ─────────────────────────────────────────
406
407// applyFee mints tokens to the recipient and to the fee recipient.
408// Returns the fee amount.
409func applyFee(ledger *grc20.PrivateLedger, to address, amount int64) int64 {
410	fee := amount * feeRate / 1000
411	// Mint full amount to recipient
412	checkErr(ledger.Mint(to, amount))
413	// Mint fee as additional supply to fee recipient
414	if fee > 0 {
415		checkErr(ledger.Mint(feeRecipient, fee))
416	}
417	return fee
418}
419
420func mustGetInstance(symbol string) *instance {
421	t, exists := instances.Get(symbol)
422	if !exists {
423		panic("token instance does not exist")
424	}
425	return t.(*instance)
426}
427
428func checkErr(err error) {
429	if err != nil {
430		panic(err.Error())
431	}
432}
433
434// ── Validation ───────────────────────────────────────────────
435
436func validateSymbol(symbol string) {
437	if len(symbol) < 1 || len(symbol) > 10 {
438		panic("symbol must be 1-10 characters")
439	}
440	for _, c := range symbol {
441		if !((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) {
442			panic("symbol must be uppercase alphanumeric (A-Z, 0-9)")
443		}
444	}
445}
446
447func validateName(name string) {
448	if len(name) < 1 || len(name) > 64 {
449		panic("name must be 1-64 characters")
450	}
451}
452
453func validateDecimals(decimals int) {
454	if decimals < 0 || decimals > 18 {
455		panic("decimals must be 0-18")
456	}
457}