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}