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