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

faucet.gno

14.26 Kb · 427 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/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}