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

faucet.gno

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