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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

faucet.gno

15.02 Kb · 458 lines
  1// Package faucet hands a small amount of GNOT to someone who has none, on the
  2// record, in two calls that are meant to travel in one transaction.
  3//
  4// The chain's own faucet is gated and the people who most need a first coin
  5// are exactly the people who cannot ask for one: an empty account cannot pay
  6// the gas to call anything. So somebody else files the request on their
  7// behalf, an approver releases it, and both halves are on chain with a reason
  8// attached.
  9//
 10// # Why two calls and not one bank send
 11//
 12// [Request] is permissionless and [Approve] is not. Splitting them puts the
 13// ask, its reason and who made it on chain even when the answer is no, and
 14// makes every payout name the request it settles. A plain send would leave a
 15// transfer with no why, and no way to refuse one in public.
 16//
 17// They are meant to be sent together. A tm2 transaction carries a LIST of
 18// messages, runs them in order and stops at the first failure, and a failed
 19// transaction writes none of their state: only the fee and the sequence
 20// survive (tm2/pkg/sdk/baseapp.go, runMsgs and WriteCheckpoint). So a request
 21// and its approval in one transaction either both happen or neither does.
 22//
 23// # The id the second message cannot know yet
 24//
 25// [Approve] names a request by id, and message 2 of a transaction cannot read
 26// what message 1 returned. A caller therefore reads [NextID] first and writes
 27// that number into the approval, which is a race: another Request landing in
 28// between shifts the id under it, and the approval would pay a stranger.
 29//
 30// That is why [Approve] also takes the recipient and the amount it believes it
 31// is approving, and refuses when the stored request disagrees. The race then
 32// costs a failed transaction instead of the wrong person's rent.
 33//
 34// # What bounds the damage
 35//
 36// The faucet spends only what has been sent to its own address, never the
 37// approver's balance, so the float is the ceiling and topping it up is a
 38// deliberate act. [MaxPerRequest] caps any single payout under that, and
 39// [Withdraw] takes the float back.
 40//
 41// # It is a private realm, which buys a code fix and costs the ledger
 42//
 43// gnomod.toml declares private = true, so the creator can redeploy this path
 44// with corrected code instead of abandoning it for a v1. That matters for a
 45// realm holding other people's rent money.
 46//
 47// The price is exact and was measured, not assumed: a redeploy re-runs every
 48// package initializer, so the float SURVIVES (coins live at the address, which
 49// is bank state) and everything on this page DOES NOT. Requests, approvers and
 50// counters all return to their init values. Fixing a bug here is therefore
 51// cheap in money and expensive in history, which is the right way round for a
 52// faucet and the wrong way round for a ledger anyone relies on.
 53//
 54// Two more consequences worth knowing before copying this: private is one-way
 55// and set at first deploy (a public path can never become private, nor the
 56// reverse), and no other realm may import this one or hold a reference to its
 57// objects.
 58package faucet
 59
 60import (
 61	"strconv"
 62	"strings"
 63
 64	"chain"
 65	"chain/banker"
 66	"chain/runtime"
 67	"chain/runtime/unsafe"
 68
 69	"gno.land/p/nt/avl/v0"
 70)
 71
 72const (
 73	// Denom is the only coin this faucet holds or pays.
 74	Denom = "ugnot"
 75	// Path is this realm's package path; its float is held at the address
 76	// derived from it.
 77	Path = "gno.land/r/nym-moultest123/faucet/v1"
 78	// Link is Path as a gnoweb route.
 79	Link = "/r/nym-moultest123/faucet/v1"
 80)
 81
 82// Owner funds the faucet, approves by default, and is the only account that
 83// can change who else approves or take the float back.
 84//
 85// Hardcoded rather than captured from the deployer: inside a plain
 86// `func Test(t *testing.T)` the gno test runner reports OriginCaller() as the
 87// EMPTY address, so an owner seeded from it is empty in every test and
 88// something else on chain. That divergence is where an authorization bug
 89// hides, so the address is written down.
 90const Owner = address("g1manfred47kzduec920z88wfr64ylksmdcedlf5")
 91
 92// The three states a request can be in. A request is decided exactly once.
 93const (
 94	StatusPending = "pending"
 95	StatusSent    = "sent"
 96	StatusDenied  = "denied"
 97)
 98
 99const (
100	// DefaultMaxPerRequest is the starting cap on a single payout, 200 GNOT.
101	// It is a guard against a fat finger, not against a hostile approver:
102	// an approver can raise nothing, but the Owner can.
103	DefaultMaxPerRequest = 200_000_000
104
105	// MaxReasonLen bounds the stored reason. It is rendered on a page, so it
106	// is both a storage cost and a display one.
107	MaxReasonLen = 280
108
109	// idWidth pads the avl key. gno's ufmt supports no width flags, so the
110	// padding is done by hand; unpadded numeric keys sort "1","10","2" and
111	// the request list would lose its order past nine entries.
112	idWidth = 12
113)
114
115// request is one ask. It is unexported because avl stores `any` and the
116// readable surface of this realm is its views and its page, not a struct
117// another realm would have to depend on.
118type request struct {
119	ID      int64
120	To      address
121	Amount  int64 // ugnot
122	Reason  string
123	By      address // who filed it, which is usually not who receives it
124	Asked   int64   // chain height at filing
125	Status  string
126	Judge   address // who decided, zero while pending
127	Decided int64   // chain height of the decision
128	Note    string  // the denial reason; empty otherwise
129}
130
131var (
132	requests  avl.Tree // padded id -> *request
133	paid      avl.Tree // recipient address -> int64 ugnot received in total
134	approvers avl.Tree // approver address -> bool
135
136	nextID        int64
137	maxPerRequest int64
138
139	totalSent   int64
140	sentCount   int64
141	deniedCount int64
142	pendingN    int64
143)
144
145func init() { reset() }
146
147// reset installs an empty faucet. Also called by the tests: realm globals
148// persist for a whole test binary and examples run after every Test, so a
149// pinned Render has to start from a known state.
150func reset() {
151	requests = avl.Tree{}
152	paid = avl.Tree{}
153	approvers = avl.Tree{}
154	approvers.Set(Owner.String(), true)
155	nextID = 1
156	maxPerRequest = DefaultMaxPerRequest
157	totalSent, sentCount, deniedCount, pendingN = 0, 0, 0, 0
158}
159
160// Address is where the float sits: this realm's own package address. Fund the
161// faucet by sending ugnot to it, with a plain bank send or with [Fund].
162func Address() address { return chain.PackageAddress(Path) }
163
164// Balance is what the faucet can actually pay out right now.
165func Balance() int64 {
166	return banker.NewReadonlyBanker().GetCoins(Address()).AmountOf(Denom)
167}
168
169// NextID is the id the next [Request] will be given.
170//
171// Read it to build the [Approve] half of a two-message transaction, and pass
172// the recipient and amount to Approve so that a request landing in between
173// fails the transaction instead of being paid by it.
174func NextID() int64 { return nextID }
175
176// MaxPerRequest is the current cap on a single payout, in ugnot.
177func MaxPerRequest() int64 { return maxPerRequest }
178
179// TotalSent, SentCount, DeniedCount and Pending are the running tallies.
180func TotalSent() int64   { return totalSent }
181func SentCount() int64   { return sentCount }
182func DeniedCount() int64 { return deniedCount }
183func Pending() int64     { return pendingN }
184
185// Requests is how many requests have ever been filed.
186func Requests() int { return requests.Size() }
187
188// IsApprover reports whether addr may approve or deny.
189func IsApprover(addr string) bool { return approvers.Has(addr) }
190
191// ReceivedBy is everything this faucet has ever paid to addr.
192func ReceivedBy(addr string) int64 {
193	if v := paid.Get(addr); v != nil {
194		return v.(int64)
195	}
196	return 0
197}
198
199// Status is the state of request id: "pending", "sent", "denied", or "" when
200// no such request exists.
201func Status(id int64) string {
202	r := find(id)
203	if r == nil {
204		return ""
205	}
206	return r.Status
207}
208
209// Fund credits the ugnot sent with the call to the float. Coins sent straight
210// to [Address] land there too; they just do not emit an event.
211func Fund(cur realm) {
212	if !cur.IsCurrent() {
213		panic("faucet: spoofed realm")
214	}
215	// OriginSend is what the SIGNER attached, not what reached this realm. A
216	// realm the user called keeps those coins and could call Fund any number of
217	// times, booking each as a donation, so only a user call may read it.
218	prev := cur.Previous()
219	if !prev.IsUserCall() {
220		panic("faucet: Fund must be called directly by an account, with -send")
221	}
222	from := prev.Address()
223	amount := unsafe.OriginSend().AmountOf(Denom)
224	if amount <= 0 {
225		panic("faucet: send some " + Denom + " with the call")
226	}
227	chain.Emit("Fund", "from", from.String(), "amount", strconv.FormatInt(amount, 10))
228}
229
230// Request files an ask for amount ugnot to be paid to `to`, and returns its
231// id. Anyone may file, for anyone, which is the point: the account that needs
232// the coins is the one that cannot pay to ask for them.
233//
234// Filing costs the filer gas and nothing else, and moves no money.
235func Request(cur realm, to string, amount int64, reason string) int64 {
236	if !cur.IsCurrent() {
237		panic("faucet: spoofed realm")
238	}
239	by := cur.Previous().Address()
240	dst := address(to)
241	if !dst.IsValid() {
242		panic("faucet: " + to + " is not a valid address")
243	}
244	if amount <= 0 {
245		panic("faucet: amount must be a positive number of " + Denom)
246	}
247	if amount > maxPerRequest {
248		panic("faucet: " + strconv.FormatInt(amount, 10) + Denom +
249			" is over the per-request cap of " + strconv.FormatInt(maxPerRequest, 10) + Denom)
250	}
251	reason = strings.TrimSpace(reason)
252	if reason == "" {
253		panic("faucet: say what it is for")
254	}
255	if len(reason) > MaxReasonLen {
256		panic("faucet: reason is longer than " + strconv.Itoa(MaxReasonLen) + " bytes")
257	}
258
259	id := nextID
260	nextID++
261	requests.Set(key(id), &request{
262		ID:     id,
263		To:     dst,
264		Amount: amount,
265		Reason: reason,
266		By:     by,
267		Asked:  runtime.ChainHeight(),
268		Status: StatusPending,
269	})
270	pendingN++
271
272	chain.Emit("Request",
273		"id", strconv.FormatInt(id, 10),
274		"to", dst.String(),
275		"amount", strconv.FormatInt(amount, 10),
276		"by", by.String(),
277	)
278	return id
279}
280
281// Approve pays request id out of the float. Approvers only.
282//
283// wantTo and wantAmount are not redundant: they are what makes it safe to put
284// Approve in the same transaction as the Request it settles. The id has to be
285// guessed from [NextID] before either message is signed, and this call refuses
286// when the request sitting at that id is not the one the caller described.
287func Approve(cur realm, id int64, wantTo string, wantAmount int64) {
288	if !cur.IsCurrent() {
289		panic("faucet: spoofed realm")
290	}
291	judge := cur.Previous().Address()
292	mustApprove(judge)
293
294	r := find(id)
295	if r == nil {
296		panic("faucet: no request " + strconv.FormatInt(id, 10))
297	}
298	if r.Status != StatusPending {
299		panic("faucet: request " + strconv.FormatInt(id, 10) + " is already " + r.Status)
300	}
301	if r.To.String() != wantTo || r.Amount != wantAmount {
302		panic("faucet: request " + strconv.FormatInt(id, 10) + " pays " +
303			strconv.FormatInt(r.Amount, 10) + Denom + " to " + r.To.String() +
304			", not " + strconv.FormatInt(wantAmount, 10) + Denom + " to " + wantTo +
305			"; the id moved under you, nothing was paid")
306	}
307	if bal := Balance(); bal < r.Amount {
308		panic("faucet: float is " + strconv.FormatInt(bal, 10) + Denom +
309			", request needs " + strconv.FormatInt(r.Amount, 10) + Denom)
310	}
311
312	banker.NewBanker(banker.BankerTypeRealmSend, cur).SendCoins(
313		cur.Address(), r.To, chain.NewCoins(chain.NewCoin(Denom, r.Amount)))
314
315	r.Status = StatusSent
316	r.Judge = judge
317	r.Decided = runtime.ChainHeight()
318	paid.Set(r.To.String(), ReceivedBy(r.To.String())+r.Amount)
319	totalSent += r.Amount
320	sentCount++
321	pendingN--
322
323	chain.Emit("Approve",
324		"id", strconv.FormatInt(id, 10),
325		"to", r.To.String(),
326		"amount", strconv.FormatInt(r.Amount, 10),
327		"by", judge.String(),
328	)
329}
330
331// Deny closes a request unpaid, with a reason that goes on the page. The
332// reason is the whole value of denying in public rather than ignoring it.
333func Deny(cur realm, id int64, why string) {
334	if !cur.IsCurrent() {
335		panic("faucet: spoofed realm")
336	}
337	judge := cur.Previous().Address()
338	mustApprove(judge)
339
340	r := find(id)
341	if r == nil {
342		panic("faucet: no request " + strconv.FormatInt(id, 10))
343	}
344	if r.Status != StatusPending {
345		panic("faucet: request " + strconv.FormatInt(id, 10) + " is already " + r.Status)
346	}
347	why = strings.TrimSpace(why)
348	if why == "" {
349		panic("faucet: say why")
350	}
351	if len(why) > MaxReasonLen {
352		panic("faucet: reason is longer than " + strconv.Itoa(MaxReasonLen) + " bytes")
353	}
354
355	r.Status = StatusDenied
356	r.Judge = judge
357	r.Decided = runtime.ChainHeight()
358	r.Note = why
359	deniedCount++
360	pendingN--
361
362	chain.Emit("Deny", "id", strconv.FormatInt(id, 10), "by", judge.String())
363}
364
365// AddApprover lets addr approve and deny. Owner only.
366func AddApprover(cur realm, addr string) {
367	if !cur.IsCurrent() {
368		panic("faucet: spoofed realm")
369	}
370	mustOwn(cur.Previous().Address())
371	a := address(addr)
372	if !a.IsValid() {
373		panic("faucet: " + addr + " is not a valid address")
374	}
375	approvers.Set(a.String(), true)
376	chain.Emit("AddApprover", "addr", a.String())
377}
378
379// RemoveApprover revokes addr. Owner only, and the Owner cannot be removed:
380// a faucet with no approver is a faucet with a locked float.
381func RemoveApprover(cur realm, addr string) {
382	if !cur.IsCurrent() {
383		panic("faucet: spoofed realm")
384	}
385	mustOwn(cur.Previous().Address())
386	if addr == Owner.String() {
387		panic("faucet: the owner is always an approver")
388	}
389	// avl's Remove returns (value, removed), unlike Get which returns one
390	// value; the comma-ok is required here and forbidden there.
391	if _, removed := approvers.Remove(addr); !removed {
392		panic("faucet: " + addr + " is not an approver")
393	}
394	chain.Emit("RemoveApprover", "addr", addr)
395}
396
397// SetMaxPerRequest changes the per-request cap, in ugnot. Owner only.
398func SetMaxPerRequest(cur realm, amount int64) {
399	if !cur.IsCurrent() {
400		panic("faucet: spoofed realm")
401	}
402	mustOwn(cur.Previous().Address())
403	if amount <= 0 {
404		panic("faucet: cap must be positive")
405	}
406	maxPerRequest = amount
407	chain.Emit("SetMaxPerRequest", "amount", strconv.FormatInt(amount, 10))
408}
409
410// Withdraw returns amount ugnot of the float to the Owner. Owner only.
411//
412// This is what makes funding the faucet reversible, and it is deliberately
413// not payable to an arbitrary address: an approver who wanted to move money
414// somewhere has to file a request for it like everyone else.
415func Withdraw(cur realm, amount int64) {
416	if !cur.IsCurrent() {
417		panic("faucet: spoofed realm")
418	}
419	mustOwn(cur.Previous().Address())
420	if amount <= 0 {
421		panic("faucet: amount must be positive")
422	}
423	if bal := Balance(); amount > bal {
424		panic("faucet: float is " + strconv.FormatInt(bal, 10) + Denom)
425	}
426	banker.NewBanker(banker.BankerTypeRealmSend, cur).SendCoins(
427		cur.Address(), Owner, chain.NewCoins(chain.NewCoin(Denom, amount)))
428	chain.Emit("Withdraw", "amount", strconv.FormatInt(amount, 10))
429}
430
431func mustOwn(caller address) {
432	if caller != Owner {
433		panic("faucet: owner only")
434	}
435}
436
437func mustApprove(caller address) {
438	if !approvers.Has(caller.String()) {
439		panic("faucet: " + caller.String() + " is not an approver")
440	}
441}
442
443func find(id int64) *request {
444	v := requests.Get(key(id))
445	if v == nil {
446		return nil
447	}
448	return v.(*request)
449}
450
451// key pads an id to idWidth digits so the avl tree iterates in filing order.
452func key(id int64) string {
453	s := strconv.FormatInt(id, 10)
454	for len(s) < idWidth {
455		s = "0" + s
456	}
457	return s
458}