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