Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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}