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