Search Apps Documentation Source Content File Folder Download Copy Actions Download

tokenfactory.gno

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