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

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}