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