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}