Search Apps Documentation Source Content File Folder Download Copy Actions Download 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}