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}