← Back to Realms

Realm

gno.land/r/moul/faucet/v1

Overview

Realm Path
gno.land/r/moul/faucet/v1
Exported Functions
21
State Entries
23
Source Files
5
Total Package Entries
56

Exported Functions

21 exported functions

State

23 state entries

Source Code

FILES
faucet.gno
go
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/moul/faucet/v1"
78	// Link is Path as a gnoweb route.
79	Link = "/r/moul/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	from := unsafe.PreviousRealm().Address()
213	amount := unsafe.OriginSend().AmountOf(Denom)
214	if amount <= 0 {
215		panic("faucet: send some " + Denom + " with the call")
216	}
217	chain.Emit("Fund", "from", from.String(), "amount", strconv.FormatInt(amount, 10))
218}
219
220// Request files an ask for amount ugnot to be paid to `to`, and returns its
221// id. Anyone may file, for anyone, which is the point: the account that needs
222// the coins is the one that cannot pay to ask for them.
223//
224// Filing costs the filer gas and nothing else, and moves no money.
225func Request(cur realm, to string, amount int64, reason string) int64 {
226	by := unsafe.PreviousRealm().Address()
227	dst := address(to)
228	if !dst.IsValid() {
229		panic("faucet: " + to + " is not a valid address")
230	}
231	if amount <= 0 {
232		panic("faucet: amount must be a positive number of " + Denom)
233	}
234	if amount > maxPerRequest {
235		panic("faucet: " + strconv.FormatInt(amount, 10) + Denom +
236			" is over the per-request cap of " + strconv.FormatInt(maxPerRequest, 10) + Denom)
237	}
238	reason = strings.TrimSpace(reason)
239	if reason == "" {
240		panic("faucet: say what it is for")
241	}
242	if len(reason) > MaxReasonLen {
243		panic("faucet: reason is longer than " + strconv.Itoa(MaxReasonLen) + " bytes")
244	}
245
246	id := nextID
247	nextID++
248	requests.Set(key(id), &request{
249		ID:     id,
250		To:     dst,
251		Amount: amount,
252		Reason: reason,
253		By:     by,
254		Asked:  runtime.ChainHeight(),
255		Status: StatusPending,
256	})
257	pendingN++
258
259	chain.Emit("Request",
260		"id", strconv.FormatInt(id, 10),
261		"to", dst.String(),
262		"amount", strconv.FormatInt(amount, 10),
263		"by", by.String(),
264	)
265	return id
266}
267
268// Approve pays request id out of the float. Approvers only.
269//
270// wantTo and wantAmount are not redundant: they are what makes it safe to put
271// Approve in the same transaction as the Request it settles. The id has to be
272// guessed from [NextID] before either message is signed, and this call refuses
273// when the request sitting at that id is not the one the caller described.
274func Approve(cur realm, id int64, wantTo string, wantAmount int64) {
275	judge := unsafe.PreviousRealm().Address()
276	mustApprove(judge)
277
278	r := find(id)
279	if r == nil {
280		panic("faucet: no request " + strconv.FormatInt(id, 10))
281	}
282	if r.Status != StatusPending {
283		panic("faucet: request " + strconv.FormatInt(id, 10) + " is already " + r.Status)
284	}
285	if r.To.String() != wantTo || r.Amount != wantAmount {
286		panic("faucet: request " + strconv.FormatInt(id, 10) + " pays " +
287			strconv.FormatInt(r.Amount, 10) + Denom + " to " + r.To.String() +
288			", not " + strconv.FormatInt(wantAmount, 10) + Denom + " to " + wantTo +
289			"; the id moved under you, nothing was paid")
290	}
291	if bal := Balance(); bal < r.Amount {
292		panic("faucet: float is " + strconv.FormatInt(bal, 10) + Denom +
293			", request needs " + strconv.FormatInt(r.Amount, 10) + Denom)
294	}
295
296	banker.NewBanker(banker.BankerTypeRealmSend, cur).SendCoins(
297		cur.Address(), r.To, chain.NewCoins(chain.NewCoin(Denom, r.Amount)))
298
299	r.Status = StatusSent
300	r.Judge = judge
301	r.Decided = runtime.ChainHeight()
302	paid.Set(r.To.String(), ReceivedBy(r.To.String())+r.Amount)
303	totalSent += r.Amount
304	sentCount++
305	pendingN--
306
307	chain.Emit("Approve",
308		"id", strconv.FormatInt(id, 10),
309		"to", r.To.String(),
310		"amount", strconv.FormatInt(r.Amount, 10),
311		"by", judge.String(),
312	)
313}
314
315// Deny closes a request unpaid, with a reason that goes on the page. The
316// reason is the whole value of denying in public rather than ignoring it.
317func Deny(cur realm, id int64, why string) {
318	judge := unsafe.PreviousRealm().Address()
319	mustApprove(judge)
320
321	r := find(id)
322	if r == nil {
323		panic("faucet: no request " + strconv.FormatInt(id, 10))
324	}
325	if r.Status != StatusPending {
326		panic("faucet: request " + strconv.FormatInt(id, 10) + " is already " + r.Status)
327	}
328	why = strings.TrimSpace(why)
329	if why == "" {
330		panic("faucet: say why")
331	}
332	if len(why) > MaxReasonLen {
333		panic("faucet: reason is longer than " + strconv.Itoa(MaxReasonLen) + " bytes")
334	}
335
336	r.Status = StatusDenied
337	r.Judge = judge
338	r.Decided = runtime.ChainHeight()
339	r.Note = why
340	deniedCount++
341	pendingN--
342
343	chain.Emit("Deny", "id", strconv.FormatInt(id, 10), "by", judge.String())
344}
345
346// AddApprover lets addr approve and deny. Owner only.
347func AddApprover(cur realm, addr string) {
348	mustOwn(unsafe.PreviousRealm().Address())
349	a := address(addr)
350	if !a.IsValid() {
351		panic("faucet: " + addr + " is not a valid address")
352	}
353	approvers.Set(a.String(), true)
354	chain.Emit("AddApprover", "addr", a.String())
355}
356
357// RemoveApprover revokes addr. Owner only, and the Owner cannot be removed:
358// a faucet with no approver is a faucet with a locked float.
359func RemoveApprover(cur realm, addr string) {
360	mustOwn(unsafe.PreviousRealm().Address())
361	if addr == Owner.String() {
362		panic("faucet: the owner is always an approver")
363	}
364	// avl's Remove returns (value, removed), unlike Get which returns one
365	// value; the comma-ok is required here and forbidden there.
366	if _, removed := approvers.Remove(addr); !removed {
367		panic("faucet: " + addr + " is not an approver")
368	}
369	chain.Emit("RemoveApprover", "addr", addr)
370}
371
372// SetMaxPerRequest changes the per-request cap, in ugnot. Owner only.
373func SetMaxPerRequest(cur realm, amount int64) {
374	mustOwn(unsafe.PreviousRealm().Address())
375	if amount <= 0 {
376		panic("faucet: cap must be positive")
377	}
378	maxPerRequest = amount
379	chain.Emit("SetMaxPerRequest", "amount", strconv.FormatInt(amount, 10))
380}
381
382// Withdraw returns amount ugnot of the float to the Owner. Owner only.
383//
384// This is what makes funding the faucet reversible, and it is deliberately
385// not payable to an arbitrary address: an approver who wanted to move money
386// somewhere has to file a request for it like everyone else.
387func Withdraw(cur realm, amount int64) {
388	mustOwn(unsafe.PreviousRealm().Address())
389	if amount <= 0 {
390		panic("faucet: amount must be positive")
391	}
392	if bal := Balance(); amount > bal {
393		panic("faucet: float is " + strconv.FormatInt(bal, 10) + Denom)
394	}
395	banker.NewBanker(banker.BankerTypeRealmSend, cur).SendCoins(
396		cur.Address(), Owner, chain.NewCoins(chain.NewCoin(Denom, amount)))
397	chain.Emit("Withdraw", "amount", strconv.FormatInt(amount, 10))
398}
399
400func mustOwn(caller address) {
401	if caller != Owner {
402		panic("faucet: owner only")
403	}
404}
405
406func mustApprove(caller address) {
407	if !approvers.Has(caller.String()) {
408		panic("faucet: " + caller.String() + " is not an approver")
409	}
410}
411
412func find(id int64) *request {
413	v := requests.Get(key(id))
414	if v == nil {
415		return nil
416	}
417	return v.(*request)
418}
419
420// key pads an id to idWidth digits so the avl tree iterates in filing order.
421func key(id int64) string {
422	s := strconv.FormatInt(id, 10)
423	for len(s) < idWidth {
424		s = "0" + s
425	}
426	return s
427}
428

Raw Package Data

Raw JSON data