coin.gno
7.29 Kb · 193 lines
1// Package coin is a GRC20 that cannot exist until its author has answered the
2// three questions a social app's token usually dodges: what mints it, what
3// burns it, and who has a reason to buy it.
4//
5// # Why a package and not a convention
6//
7// Every app in the x/social family is asked the same question, "and can it
8// have a token", and the honest answer is only yes when all three of those
9// have an answer. A token with a mint rule and no sink is a scoreboard with a
10// price: supply grows, nothing consumes it, and the number on the leaderboard
11// is the whole product. That is the shape a chain-wide measurement keeps
12// finding, and it is cheap to avoid, so [New] refuses a [Policy] with a blank
13// field rather than letting the omission ship.
14//
15// The three strings are not validated beyond being non-empty, because no
16// package can check that a sink is real. They are a declaration, rendered on
17// the realm's own page by [Coin.Render], where being wrong is visible.
18//
19// # The model
20//
21// earned minted by the app, for the behaviour the app wants more of
22// spent burned by the app, for the thing holders actually want
23// traded a plain GRC20 transfer, which is what makes the sink a market
24//
25// Earning and spending are the app's business and go through [Coin.Earn] and
26// [Coin.Spend], which keep running totals so a reader can see the two halves
27// against each other. Transfers, approvals and balances are the embedded
28// [grc20.Token]'s, unchanged, so wallets and indexers see an ordinary GRC20.
29//
30// # Usage
31//
32// A realm holds one Coin and never exports the ledger:
33//
34// var points = coin.New("Thread Points", "THREAD", 0, 0, coin.Policy{
35// Mint: "1 per distinct address that replies to your thread",
36// Sink: "burned to pin a thread to the top of its page",
37// Buyer: "anyone who wants placement and has not earned it",
38// }, 0, cur)
39//
40// The trailing `_ int, rlm realm` is the shape a pure package has to use to
41// reach the caller's frame: a p/ package may not declare a crossing function,
42// so the realm token is threaded as a later parameter, exactly as grc20's own
43// tellers do.
44package coin
45
46import (
47 "gno.land/p/moul/kit/num/v0"
48 "gno.land/p/moul/kit/ui/v0"
49 "gno.land/p/moul/md/v0"
50 "gno.land/p/nt/grc20/v0"
51 "gno.land/p/nt/seqid/v0"
52 "gno.land/p/nt/ufmt/v0"
53)
54
55// Policy is the three declarations a Coin cannot be created without.
56//
57// Each one is a sentence for a human: it is rendered on the issuing realm's
58// page and nothing branches on it.
59type Policy struct {
60 // Mint says what behaviour earns the token, precisely enough that a
61 // reader can work out whether they can farm it.
62 Mint string
63
64 // Sink says what destroys it. "Nothing" is not an answer; if there is
65 // no sink, there is no reason for the token to exist.
66 Sink string
67
68 // Buyer says who wants it badly enough to acquire it from someone who
69 // earned it. This is the one most often left blank, and the one that
70 // decides whether the other two matter.
71 Buyer string
72}
73
74// Valid reports whether every field is filled in.
75func (p Policy) Valid() bool {
76 return p.Mint != "" && p.Sink != "" && p.Buyer != ""
77}
78
79// Coin is a GRC20 plus the policy it was issued under and the running totals
80// of the two flows the policy describes.
81type Coin struct {
82 tok *grc20.Token
83 led *grc20.PrivateLedger
84 policy Policy
85
86 earned int64 // cumulative minted
87 spent int64 // cumulative burned
88}
89
90// New issues the token. It panics when the policy is incomplete, which is the
91// whole point of the package: the declaration happens before the first unit
92// exists, not in a README written afterwards.
93//
94// name, symbol, decimals and id are grc20's own; id distinguishes two tokens
95// issued by the same realm.
96func New(name, symbol string, decimals int, id seqid.ID, policy Policy, _ int, rlm realm) *Coin {
97 if !policy.Valid() {
98 panic("coin: a token needs a mint rule, a sink and a buyer; one of the three is empty")
99 }
100 tok, led := grc20.NewToken(name, symbol, decimals, id, rlm)
101 return &Coin{tok: tok, led: led, policy: policy}
102}
103
104// Token returns the GRC20 itself, for the realm to expose as its read API and
105// to register with a token registry.
106func (c *Coin) Token() *grc20.Token { return c.tok }
107
108// CallerTeller is the teller that acts as the user who called the realm: it
109// moves their own balance and nothing else.
110//
111// It is exposed, where the ledger is not, because transfer and approve are
112// what make the sink a market. Mint and burn stay behind [Coin.Earn] and
113// [Coin.Spend] so the mint rule keeps exactly one call site.
114func (c *Coin) CallerTeller() grc20.Teller { return c.led.CallerTeller() }
115
116// Policy returns the declarations the token was issued under.
117func (c *Coin) Policy() Policy { return c.policy }
118
119// Earn mints amount to addr. The realm calls it from the one place its mint
120// rule is implemented, so that rule has exactly one call site.
121//
122// A non-positive amount is a no-op rather than an abort: a mint rule that
123// computes zero (nothing new happened) is a normal outcome and should not
124// fail the transaction that discovered it.
125func (c *Coin) Earn(to address, amount int64) {
126 if amount <= 0 {
127 return
128 }
129 if err := c.led.Mint(to, amount); err != nil {
130 panic("coin: mint: " + err.Error())
131 }
132 c.earned += amount
133}
134
135// Spend burns amount from addr, which is how the sink consumes supply.
136//
137// It aborts when the balance is short, naming both numbers, because the
138// caller is a user who is about to be told they cannot afford something.
139func (c *Coin) Spend(from address, amount int64) {
140 if amount <= 0 {
141 panic("coin: spend: amount must be positive")
142 }
143 if bal := c.tok.BalanceOf(from); bal < amount {
144 panic(ufmt.Sprintf("coin: balance %d is short of %d %s", bal, amount, c.tok.GetSymbol()))
145 }
146 if err := c.led.Burn(from, amount); err != nil {
147 panic("coin: burn: " + err.Error())
148 }
149 c.spent += amount
150}
151
152// BalanceOf is the holder's balance.
153func (c *Coin) BalanceOf(addr address) int64 { return c.tok.BalanceOf(addr) }
154
155// Supply is what exists right now, that is, earned minus spent.
156func (c *Coin) Supply() int64 { return c.tok.TotalSupply() }
157
158// Earned and Spent are the cumulative flows. Their difference is [Coin.Supply]
159// and they are kept separately because a sink that never fires is invisible in
160// the supply alone: a flat line reads the same as no sink at all.
161func (c *Coin) Earned() int64 { return c.earned }
162
163// Spent is the cumulative amount burned through [Coin.Spend].
164func (c *Coin) Spent() int64 { return c.spent }
165
166// Holders is how many addresses the ledger knows about.
167func (c *Coin) Holders() int { return c.tok.KnownAccounts() }
168
169// Render is the block a realm puts on its own page: the three declarations,
170// then the two flows against each other.
171//
172// The policy strings are escaped: they are constants in practice, but a realm
173// could build one from configuration, and this package cannot tell.
174func (c *Coin) Render() string {
175 out := md.H3(ui.Inline(c.tok.GetName()) + " (" + ui.Inline(c.tok.GetSymbol()) + ")")
176
177 t := ui.NewTable("", "")
178 t.Row("earns", ui.Cell(c.policy.Mint))
179 t.Row("burns", ui.Cell(c.policy.Sink))
180 t.Row("bought by", ui.Cell(c.policy.Buyer))
181 out += t.String()
182
183 s := ui.NewTable("supply", "earned", "burned", "holders")
184 s.Row(
185 num.Dec(c.Supply(), c.tok.GetDecimals()),
186 num.Dec(c.earned, c.tok.GetDecimals()),
187 num.Dec(c.spent, c.tok.GetDecimals()),
188 ufmt.Sprintf("%d", c.Holders()),
189 )
190 out += s.String()
191
192 return out
193}