Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

v0 source realm

Package faucet hands a small amount of GNOT to someone who has none, on the record, in two calls that are meant to tr...

Readme View source

GNOT faucet

A two-step, on-the-record way to get a small amount of GNOT to someone who has none: anyone files a request on their behalf, an approver releases it, and both halves stay on the page with a reason attached.

It exists because the accounts that most need a first coin are exactly the ones that cannot ask for it. An empty account cannot pay the gas to call anything, so Request takes the recipient as an argument and the filer pays for the ask.

This is not the grant board. The grant board weighs work against milestones and proofs; this hands over pocket change so somebody can try the chain at all.

The float

The faucet spends only what has been sent to its own package address, never the approver's balance. Top it up with a plain bank send to that address, or with Fund if you want the donation on the record. Withdraw takes it back, to the owner and nowhere else.

The two calls, and why they travel together

call who what it does
Request(to, amount, reason) anyone files an ask, returns its id, moves no money
Approve(id, to, amount) an approver pays it out of the float
Deny(id, why) an approver closes it unpaid, with the reason on the page
Fund() anyone, payable credits the float and emits an event
Withdraw(amount) the owner returns float to the owner
AddApprover / RemoveApprover / SetMaxPerRequest the owner the knobs

A tm2 transaction carries a list of messages, runs them in order, and stops at the first failure; a failed transaction writes none of their state, only the fee and the sequence survive. So Request and Approve sent as two messages of one transaction either both happen or neither does, and the common case is a single signature.

The id the second message cannot know

Approve names a request by id, and message 2 of a transaction cannot read what message 1 returned. The caller reads NextID before signing and writes that number into the approval, which is a race: another request landing in between shifts the id, and the approval would pay a stranger.

That is why Approve also takes the recipient and the amount it believes it is approving, and aborts when the stored request disagrees. The race then costs a failed transaction instead of the wrong person's money.

Calls

 1# Ask, on someone else's behalf. Amount is in ugnot.
 2gnokey maketx call -pkgpath "gno.land/r/moul/faucet/v0" -func Request \
 3  -args "g1..." -args 100000000 -args "no gas, wants to try the chain" \
 4  -gas-fee 1000000ugnot -gas-wanted 3000000 -broadcast -chainid gnoland-1 moul
 5
 6# What id the next request will get, so an approval can be signed alongside it.
 7gnokey query vm/qeval -data 'gno.land/r/moul/faucet/v0.NextID()'
 8
 9# What the float holds.
10gnokey query vm/qeval -data 'gno.land/r/moul/faucet/v0.Balance()'
11
12# The page.
13gnokey query vm/qrender -data 'gno.land/r/moul/faucet/v0:'

Reading it

Render("") is the board: float, open requests, decided ones, approvers. Render("req/<id>") is one request, with its reason and, if it was refused, why.

Every caller-supplied string on those pages is escaped before it is rendered. A reason is arbitrary text from an arbitrary account and Render output is markdown that gnoweb parses, so an unescaped one can plant a link, an image beacon or page chrome on a page the realm signs for. This path is permanent, so that bug could only ever be fixed at a new path.


Part of moul/gno-contracts — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage.

Dependency graph:

gno.land/r/moul/faucet/v0 dependency graph

⚠️ Disclaimer: provided as-is, without warranty; not security-audited. Full disclaimer: DISCLAIMER.

Overview

Package faucet hands a small amount of GNOT to someone who has none, on the record, in two calls that are meant to travel in one transaction.

The chain's own faucet is gated and the people who most need a first coin are exactly the people who cannot ask for one: an empty account cannot pay the gas to call anything. So somebody else files the request on their behalf, an approver releases it, and both halves are on chain with a reason attached.

Why two calls and not one bank send

Request is permissionless and Approve is not. Splitting them puts the ask, its reason and who made it on chain even when the answer is no, and makes every payout name the request it settles. A plain send would leave a transfer with no why, and no way to refuse one in public.

They are meant to be sent together. A tm2 transaction carries a LIST of messages, runs them in order and stops at the first failure, and a failed transaction writes none of their state: only the fee and the sequence survive (tm2/pkg/sdk/baseapp.go, runMsgs and WriteCheckpoint). So a request and its approval in one transaction either both happen or neither does.

The id the second message cannot know yet

Approve names a request by id, and message 2 of a transaction cannot read what message 1 returned. A caller therefore reads NextID first and writes that number into the approval, which is a race: another Request landing in between shifts the id under it, and the approval would pay a stranger.

That is why Approve also takes the recipient and the amount it believes it is approving, and refuses when the stored request disagrees. The race then costs a failed transaction instead of the wrong person's rent.

What bounds the damage

The faucet spends only what has been sent to its own address, never the approver's balance, so the float is the ceiling and topping it up is a deliberate act. MaxPerRequest caps any single payout under that, and Withdraw takes the float back.

Constants 4

const Denom, Path, Link

1const (
2	// Denom is the only coin this faucet holds or pays.
3	Denom = "ugnot"
4	// Path is this realm's package path; its float is held at the address
5	// derived from it.
6	Path = "gno.land/r/moul/faucet/v0"
7	// Link is Path as a gnoweb route.
8	Link = "/r/moul/faucet/v0"
9)
source

const DefaultMaxPerRequest, MaxReasonLen, idWidth

 1const (
 2	// DefaultMaxPerRequest is the starting cap on a single payout, 200 GNOT.
 3	// It is a guard against a fat finger, not against a hostile approver:
 4	// an approver can raise nothing, but the Owner can.
 5	DefaultMaxPerRequest = 200_000_000
 6
 7	// MaxReasonLen bounds the stored reason. It is rendered on a page, so it
 8	// is both a storage cost and a display one.
 9	MaxReasonLen = 280
10
11	// idWidth pads the avl key. gno's ufmt supports no width flags, so the
12	// padding is done by hand; unpadded numeric keys sort "1","10","2" and
13	// the request list would lose its order past nine entries.
14	idWidth = 12
15)
source

const Owner

1const Owner = address("g1manfred47kzduec920z88wfr64ylksmdcedlf5")
source

Owner funds the faucet, approves by default, and is the only account that can change who else approves or take the float back.

Hardcoded rather than captured from the deployer: inside a plain `func Test(t *testing.T)` the gno test runner reports OriginCaller() as the EMPTY address, so an owner seeded from it is empty in every test and something else on chain. That divergence is where an authorization bug hides, so the address is written down.

Functions 20

func AddApprover

crossing Action
1func AddApprover(cur realm, addr string)
source

AddApprover lets addr approve and deny. Owner only.

func Approve

crossing Action
1func Approve(cur realm, id int64, wantTo string, wantAmount int64)
source

Approve pays request id out of the float. Approvers only.

wantTo and wantAmount are not redundant: they are what makes it safe to put Approve in the same transaction as the Request it settles. The id has to be guessed from NextID before either message is signed, and this call refuses when the request sitting at that id is not the one the caller described.

func Balance

Action
1func Balance() int64
source

Balance is what the faucet can actually pay out right now.

func Deny

crossing Action
1func Deny(cur realm, id int64, why string)
source

Deny closes a request unpaid, with a reason that goes on the page. The reason is the whole value of denying in public rather than ignoring it.

func Fund

crossing Action
1func Fund(cur realm)
source

Fund credits the ugnot sent with the call to the float. Coins sent straight to [Address] land there too; they just do not emit an event.

func NextID

Action
1func NextID() int64
source

NextID is the id the next Request will be given.

Read it to build the Approve half of a two-message transaction, and pass the recipient and amount to Approve so that a request landing in between fails the transaction instead of being paid by it.

func ReceivedBy

Action
1func ReceivedBy(addr string) int64
source

ReceivedBy is everything this faucet has ever paid to addr.

func RemoveApprover

crossing Action
1func RemoveApprover(cur realm, addr string)
source

RemoveApprover revokes addr. Owner only, and the Owner cannot be removed: a faucet with no approver is a faucet with a locked float.

func Render

1func Render(path string) string
source

Render shows the float, the open asks and everything already decided.

Two paths: "" is the whole board, "req/<id>" is one request.

Every string on this page that a caller typed goes through sanitize before it is concatenated. A reason is arbitrary text from an arbitrary account and Render output is markdown that gnoweb parses, so an unescaped one can plant a link, an image beacon or gnoweb chrome on a page the realm is signing for. This realm's path is permanent, so that bug could only ever be fixed at a new path.

func Request

crossing Action
1func Request(cur realm, to string, amount int64, reason string) int64
source

Request files an ask for amount ugnot to be paid to `to`, and returns its id. Anyone may file, for anyone, which is the point: the account that needs the coins is the one that cannot pay to ask for them.

Filing costs the filer gas and nothing else, and moves no money.

func SetMaxPerRequest

crossing Action
1func SetMaxPerRequest(cur realm, amount int64)
source

SetMaxPerRequest changes the per-request cap, in ugnot. Owner only.

func Status

Action
1func Status(id int64) string
source

Status is the state of request id: "pending", "sent", "denied", or "" when no such request exists.

func TotalSent

Action
1func TotalSent() int64
source

TotalSent, SentCount, DeniedCount and Pending are the running tallies.

func Withdraw

crossing Action
1func Withdraw(cur realm, amount int64)
source

Withdraw returns amount ugnot of the float to the Owner. Owner only.

This is what makes funding the faucet reversible, and it is deliberately not payable to an arbitrary address: an approver who wanted to move money somewhere has to file a request for it like everyone else.

Imports 8

Source Files 6