moultest.gno
10.41 Kb · 292 lines
1// Package moultest issues `moultest`, a NATIVE coin, and gives it away.
2//
3// Native here means the chain's own bank holds it, exactly as it holds GNOT.
4// It is not a GRC20: there is no ledger in this realm's storage, no balance
5// map, no Transfer function, no allowance. A realm with the right banker calls
6// IssueCoin once and from that moment the coin is a first-class chain object,
7// and this realm has no further say in who holds it.
8//
9// # What that buys, and what it costs
10//
11// The interesting half is what disappears. Moving moultest needs no code here
12// at all: a plain bank send does it, from any wallet, with no realm call and no
13// approval dance, because the transfer is a tm2 bank message rather than a
14// function call. It can also RIDE a transaction: the `-send` envelope of a call
15// carries it into the realm being called, which is the one thing no GRC20 can
16// do, and [Tip] exists to show it. The account page of any explorer lists it
17// beside GNOT with nothing registered anywhere.
18//
19// The half that is worse is the authority. A GRC20's mint and burn live behind
20// a PrivateLedger this realm chooses to guard; a native coin's live in a banker
21// minted from a realm handle, and banker.RemoveCoin takes an ARBITRARY address.
22// Nothing in the chain stops the issuing realm from deleting anybody's balance
23// at any time. This realm does not expose that (see [Burn], which is scoped to
24// the caller and to nobody else), but "does not expose it" is the only
25// protection there is, and it lasts exactly as long as the deployed code. That
26// is the real asymmetry against a GRC20, not the ergonomics.
27//
28// There is no decimals field either, nor a name, nor a symbol: a native denom
29// is a string and nothing else. 1000 moultest is a thousand moultest.
30//
31// # Why it is capped
32//
33// Free money with no ceiling is a spam vector rather than a faucet, so [Claim]
34// is bounded four ways, and the caps are the point of the experiment as much as
35// the coin is:
36//
37// - [ClaimAmount] per call, to the CALLER only, never to an address the
38// caller names, so nobody can dust a stranger with it.
39// - [ClaimEvery] blocks of cooldown between two claims by one account.
40// - [MaxPerAccount] over that account's lifetime.
41// - [MaxSupply] over the realm's lifetime.
42//
43// [Burn] does not give any of it back. Issuance is counted monotonically, so
44// claim-burn-claim cannot walk around the per-account cap; what burning moves
45// is the circulating supply, which is why the two numbers are reported
46// separately.
47//
48// # It is a private realm
49//
50// gnomod.toml declares private = true, so this path can be redeployed with
51// corrected code instead of being abandoned for a v1. The price is measured
52// elsewhere in this repo and applies here too: the coins survive a redeploy
53// (they are bank state, held at their owners' addresses, and the denom embeds
54// the package path, which does not move), and everything on this page does not.
55// Claim history, caps consumed and the tip board all return to their init
56// values, which would hand every account a fresh [MaxPerAccount]. For a coin
57// that is worthless by construction that is an acceptable trade; it would not
58// be for one that is not.
59package moultest
60
61import (
62 "chain"
63 "chain/banker"
64 "chain/runtime"
65 "chain/runtime/unsafe"
66
67 "gno.land/p/nt/avl/v0"
68 "gno.land/p/nt/ufmt/v0"
69)
70
71const (
72 // Name is the coin's base denom: the part after the colon. The chain caps
73 // it at 16 lowercase characters.
74 Name = "moultest"
75 // Path is this realm's package path. The denom embeds it verbatim, so the
76 // coin and the code are permanently the same name.
77 Path = "gno.land/r/moul/x/moultest/v0"
78 // Link is Path as a gnoweb route.
79 Link = "/r/moul/x/moultest/v0"
80)
81
82// Denom is the full chain denom, "/" + [Path] + ":" + [Name]. This is the
83// string a wallet, an explorer or a `-send` flag needs; the bank knows nothing
84// about the realm behind it.
85var Denom = chain.CoinDenom(Path, Name)
86
87const (
88 // ClaimAmount is issued per successful [Claim].
89 ClaimAmount = int64(10_000)
90 // ClaimEvery is the cooldown, in blocks, between two claims by one account.
91 ClaimEvery = int64(100)
92 // MaxPerAccount is the lifetime ceiling on what one address can claim here,
93 // ten claims' worth. Burning does not raise it.
94 MaxPerAccount = int64(100_000)
95 // MaxSupply is the lifetime ceiling on what this realm can ever issue: a
96 // hundred claims' worth, all told. Nothing lowers it, so the coin cannot be
97 // inflated after the fact, and the experiment has an end rather than a
98 // budget. A faucet meant to serve a crowd would put the ceiling somewhere
99 // else; this one is meant to run out.
100 MaxSupply = int64(1_000_000)
101
102 // MaxNote is the longest tip note kept, in bytes.
103 MaxNote = 120
104 // MaxTips is how many tips the board shows. Older ones fall off.
105 MaxTips = 20
106)
107
108type account struct {
109 lastClaim int64 // block height of the most recent claim
110 claims int64 // how many times this address has claimed
111 issued int64 // lifetime total issued to it, never decremented
112}
113
114// Tip is one entry of the public board written by [Tip].
115type tip struct {
116 from address
117 to address
118 amount int64
119 note string
120 height int64
121}
122
123var (
124 accounts = avl.NewTree() // address string -> *account
125
126 // issued and burned are counted here rather than derived from the bank:
127 // TotalCoin reports what CIRCULATES, and the caps are about what was ever
128 // minted. The two differ by exactly `burned`, which a test pins.
129 issued int64
130 burned int64
131
132 tips []*tip // most recent last, at most MaxTips
133)
134
135// Claim issues [ClaimAmount] of moultest to the caller and returns it.
136//
137// It never issues to an address the caller names. That is deliberate: a faucet
138// that mints to a third party is a way to spray a denom nobody asked for into
139// strangers' wallets, and their account page then carries it forever.
140func Claim(cur realm) int64 {
141 who := caller(cur)
142 a := accountOf(who)
143 now := runtime.ChainHeight()
144
145 if a.claims > 0 && now < a.lastClaim+ClaimEvery {
146 panic(ufmt.Sprintf("too soon: next claim at block %d, current %d",
147 a.lastClaim+ClaimEvery, now))
148 }
149 if a.issued+ClaimAmount > MaxPerAccount {
150 panic(ufmt.Sprintf("account cap reached: %d of %d already claimed by this address",
151 a.issued, MaxPerAccount))
152 }
153 if issued+ClaimAmount > MaxSupply {
154 panic(ufmt.Sprintf("supply cap reached: %d of %d already issued",
155 issued, MaxSupply))
156 }
157
158 a.lastClaim = now
159 a.claims++
160 a.issued += ClaimAmount
161 accounts.Set(who.String(), a)
162 issued += ClaimAmount
163
164 banker.NewBanker(banker.BankerTypeRealmIssue, cur).IssueCoin(who, Denom, ClaimAmount)
165 return ClaimAmount
166}
167
168// Burn destroys `amount` of the CALLER's moultest, and only the caller's.
169//
170// The banker this realm holds could remove coins from any address on the chain
171// without asking, so the `who` here is read from the call frame and is never a
172// parameter. A clawback is what an issuing realm is always able to write; the
173// choice not to is made here, once, and cannot be revisited without a redeploy.
174//
175// Burning lowers what circulates and does not return any cap headroom: see
176// [MaxPerAccount].
177func Burn(cur realm, amount int64) {
178 if amount <= 0 {
179 panic("burn a positive amount")
180 }
181 who := caller(cur)
182 if bal := BalanceOf(who); bal < amount {
183 panic(ufmt.Sprintf("balance is %d, cannot burn %d", bal, amount))
184 }
185
186 banker.NewBanker(banker.BankerTypeRealmIssue, cur).RemoveCoin(who, Denom, amount)
187 burned += amount
188}
189
190// Tip forwards the moultest attached to THIS transaction to `to`, and writes
191// the note on a public board.
192//
193// The coin arrives in the `-send` envelope of the call, credited to this
194// realm's address before a line of this function runs, and leaves through a
195// BankerTypeOriginSend banker, whose whole authority is that envelope: it can
196// spend what this message paid in and not one coin more, not even out of the
197// realm's own balance. So the realm can route a payment it was handed while
198// being structurally unable to touch anything else.
199//
200// A GRC20 has no equivalent. Its tokens cannot ride a message, so the same flow
201// costs an Approve, then a call, then an allowance that outlives both.
202//
203// Anything else in the envelope (the GNOT covering a storage deposit, say) is
204// left alone and stays with the realm.
205func Tip(cur realm, to address, note string) int64 {
206 who := caller(cur)
207 if !to.IsValid() {
208 panic("tip a valid address")
209 }
210 if to == who {
211 panic("tip somebody else")
212 }
213 if len(note) > MaxNote {
214 panic(ufmt.Sprintf("note is %d bytes, max %d", len(note), MaxNote))
215 }
216
217 amount := unsafe.OriginSend().AmountOf(Denom)
218 if amount <= 0 {
219 panic("attach moultest to the transaction: -send " + Denom)
220 }
221
222 b := banker.NewBanker(banker.BankerTypeOriginSend, cur)
223 b.SendCoins(cur.Address(), to, chain.NewCoins(chain.NewCoin(Denom, amount)))
224
225 tips = append(tips, &tip{
226 from: who,
227 to: to,
228 amount: amount,
229 note: note,
230 height: runtime.ChainHeight(),
231 })
232 if len(tips) > MaxTips {
233 tips = tips[len(tips)-MaxTips:]
234 }
235 return amount
236}
237
238// BalanceOf returns what the bank holds of moultest for `who`. It reads the
239// chain, not this realm: a balance this realm never saw counts the same.
240func BalanceOf(who address) int64 {
241 return banker.NewReadonlyBanker().GetCoin(who, Denom)
242}
243
244// Circulating returns how much moultest exists right now, from the bank.
245func Circulating() int64 {
246 return banker.NewReadonlyBanker().TotalCoin(Denom)
247}
248
249// Issued returns how much this realm has ever issued. It only grows, and it is
250// what [MaxSupply] caps.
251func Issued() int64 { return issued }
252
253// Burned returns how much has been destroyed through [Burn].
254func Burned() int64 { return burned }
255
256// NextClaim returns the block height at which `who` may claim again, and
257// whether they have ever claimed at all.
258func NextClaim(who address) (int64, bool) {
259 a := accounts.Get(who.String())
260 if a == nil {
261 return 0, false
262 }
263 return a.(*account).lastClaim + ClaimEvery, true
264}
265
266// ClaimedBy returns the lifetime total issued to `who` by this realm, which is
267// what [MaxPerAccount] caps.
268func ClaimedBy(who address) int64 {
269 a := accounts.Get(who.String())
270 if a == nil {
271 return 0
272 }
273 return a.(*account).issued
274}
275
276// Accounts returns how many addresses have ever claimed.
277func Accounts() int { return accounts.Size() }
278
279func accountOf(who address) *account {
280 if v := accounts.Get(who.String()); v != nil {
281 return v.(*account)
282 }
283 return &account{}
284}
285
286// caller is the account or realm that crossed into this one.
287func caller(cur realm) address {
288 if !cur.IsCurrent() {
289 panic("moultest: stale realm token")
290 }
291 return cur.Previous().Address()
292}