grant.gno
8.95 Kb · 214 lines
1// Package grant is moul's personal grant board.
2//
3// It is small on purpose. moul funds it out of his own pocket, moul is the
4// only member, and it exists mainly so the coding agents working on his repos
5// and the people he already works with have a way to ask for money against
6// work they can prove they did: a request, a milestone, a link to the merged
7// PR, a tranche. It is open to anyone, not only to agents, and nothing here is
8// privileged to a bot.
9//
10// # What this is NOT
11//
12// It is not a faucet, and it is not an official gno.land grant program. There
13// is no entitlement, no queue and no service level: a request can sit here
14// unanswered, or be refused with one line of reasoning and nothing further.
15// The official thing, where anyone asks for tokens and a DAO decides, is a
16// separate program that does not exist yet, at a path this realm deliberately
17// does not name: naming one would read as a commitment nobody has made. This
18// realm is moul's own money and a proof of concept for the library under it.
19//
20// A bigger, multi-member, better funded program is a separate thing and will
21// live at its own path. This one stays personal, and its limits are on the
22// page rather than in a comment: one member means "a majority of the board" is
23// one signature, and the board page says so.
24//
25// # What is here versus in the library
26//
27// Almost nothing. Every rule, every tally, every markdown page comes from
28// gno.land/p/moul/grants/v0, which is pure and has no idea coins exist. This
29// file is the chain-facing half: it turns the caller into an address and the
30// block into a height, holds the one Program, moves ugnot through the banker,
31// and emits events. That is deliberate, so the next board (multi-member,
32// differently funded, maybe a different denom) is another file this short and
33// not a fork of this one.
34//
35// # Who the caller is
36//
37// Every actor is PreviousRealm().Address(), the immediate caller. Called
38// through another realm that is the calling REALM, not the user behind it.
39// An agent holding a gno.land account session scoped to this path applies and
40// submits proofs as its own address, which is exactly what makes an agent's
41// track record on this board its own and not moul's.
42package grant
43
44import (
45 "strconv"
46
47 "chain"
48 "chain/banker"
49 "chain/runtime"
50 "chain/runtime/unsafe"
51
52 "gno.land/p/moul/grants/v0"
53)
54
55const (
56 // Denom is the only coin this board holds or pays.
57 Denom = "ugnot"
58 // Path is this realm's package path; its treasury is derived from it.
59 Path = "gno.land/r/moul/grant/v0"
60 // Link is Path as a gnoweb route.
61 Link = "/r/moul/grant/v0"
62)
63
64// Owner is the founding and, for now, only member.
65//
66// Hardcoded rather than taken from the deployer: inside a plain
67// `func Test(t *testing.T)` the gno test runner reports OriginCaller() as the
68// EMPTY address, so a board seeded from it is empty in every test and
69// something else on chain. That divergence is exactly where an authorization
70// bug hides, so the address is written down.
71const Owner = address("g1manfred47kzduec920z88wfr64ylksmdcedlf5")
72
73var program *grants.Program
74
75func init() { reset() }
76
77// reset installs a fresh, empty board. Also called by the tests: realm globals
78// persist for a whole test binary and examples run after every Test, so a
79// pinned Render has to start from a known state.
80func reset() { program = grants.NewProgram(Denom, Owner) }
81
82// Address is the treasury: this realm's own package address. Fund it by
83// sending ugnot to it, or by calling Fund with `-send`.
84func Address() address { return chain.PackageAddress(Path) }
85
86// Balance is what the treasury actually holds.
87func Balance() int64 {
88 return banker.NewReadonlyBanker().GetCoins(Address()).AmountOf(Denom)
89}
90
91// Committed is what approved grants can still claim, Available is the balance
92// minus that, and it can go negative. See the library for why.
93func Committed() int64 { return program.Committed() }
94func Available() int64 { return program.Available(Balance()) }
95
96// Raised is everything donated through Fund, Disbursed everything paid out.
97func Raised() int64 { return program.Raised() }
98func Disbursed() int64 { return program.Disbursed() }
99
100// Members lists the board.
101func Members() []address { return program.Board.Members() }
102
103// Requests is how many requests have ever been filed.
104func Requests() int { return program.Board.Size() }
105
106// Fund credits the ugnot sent with the call to the treasury and puts the donor
107// on the record. Coins sent to the realm address directly still land in the
108// treasury; they just do not get a name next to them.
109func Fund(cur realm) {
110 from := unsafe.PreviousRealm().Address()
111 amount := unsafe.OriginSend().AmountOf(Denom)
112 if err := program.Fund(from, amount, runtime.ChainHeight()); err != nil {
113 panic(err)
114 }
115 chain.Emit("Fund", "from", from.String(), "amount", strconv.FormatInt(amount, 10))
116}
117
118// Apply files a grant request for the caller. milestones reads
119// "title:amount,title:amount", amounts in ugnot, paid in the order given.
120// Returns the request id.
121func Apply(cur realm, title, body, milestones string) int {
122 applicant := unsafe.PreviousRealm().Address()
123 return file(applicant, applicant, "", title, body, milestones)
124}
125
126// ApplyFor files a grant request whose tranches pay beneficiary rather than
127// the caller, with a reason for the detour that goes on the request page.
128//
129// This is the call that makes the board usable at all for the case it exists
130// to serve. An account with nothing in it cannot pay the gas to ask for its
131// first coins, so without someone else filing on its behalf the people who
132// most need a small grant are exactly the people who cannot ask for one.
133// Either address may then submit the proofs, and neither may vote.
134func ApplyFor(cur realm, beneficiary, reason, title, body, milestones string) int {
135 applicant := unsafe.PreviousRealm().Address()
136 return file(applicant, address(beneficiary), reason, title, body, milestones)
137}
138
139func file(applicant, beneficiary address, reason, title, body, milestones string) int {
140 r, err := program.ApplyFor(applicant, beneficiary, reason, title, body, milestones,
141 runtime.ChainHeight())
142 if err != nil {
143 panic(err)
144 }
145 chain.Emit("Apply", "id", strconv.Itoa(r.ID), "applicant", applicant.String(),
146 "beneficiary", r.Payee().String(), "total", strconv.FormatInt(r.Total(), 10))
147 return r.ID
148}
149
150// ProposeMember asks the board to add or remove a member. Members only.
151func ProposeMember(cur realm, addr string, add bool, body string) int {
152 proposer := unsafe.PreviousRealm().Address()
153 r, err := program.Board.SubmitMemberChange(proposer, address(addr), add, body, runtime.ChainHeight())
154 if err != nil {
155 panic(err)
156 }
157 chain.Emit("ProposeMember", "id", strconv.Itoa(r.ID), "subject", addr,
158 "add", strconv.FormatBool(add))
159 return r.ID
160}
161
162// Retract pulls the caller's own request before it is decided.
163func Retract(cur realm, id int) {
164 caller := unsafe.PreviousRealm().Address()
165 if err := program.Board.Withdraw(caller, id, runtime.ChainHeight()); err != nil {
166 panic(err)
167 }
168 chain.Emit("Retract", "id", strconv.Itoa(id))
169}
170
171// Vote records a member's ballot. The reason is stored and rendered next to
172// the vote: it is the only part of a decision that tells an applicant what to
173// do about it.
174func Vote(cur realm, id int, approve bool, reason string) {
175 voter := unsafe.PreviousRealm().Address()
176 st, err := program.Board.Vote(voter, id, approve, reason, runtime.ChainHeight())
177 if err != nil {
178 panic(err)
179 }
180 chain.Emit("Vote", "id", strconv.Itoa(id), "voter", voter.String(),
181 "approve", strconv.FormatBool(approve), "status", st.String())
182}
183
184// SubmitProof offers evidence for the next milestone of an approved grant.
185// kind is "url", "hash" or "text"; ref is the link, digest or statement.
186func SubmitProof(cur realm, id, milestone int, kind, ref, note string) {
187 caller := unsafe.PreviousRealm().Address()
188 p := grants.Proof{Kind: kind, Ref: ref, Note: note, Height: runtime.ChainHeight()}
189 if err := program.Board.SubmitProof(caller, id, milestone, p); err != nil {
190 panic(err)
191 }
192 chain.Emit("SubmitProof", "id", strconv.Itoa(id),
193 "milestone", strconv.Itoa(milestone), "kind", kind)
194}
195
196// Review records a member's verdict on the proof under review. The verdict
197// that carries also pays the tranche, in this same transaction. A verdict the
198// treasury cannot cover is refused outright and changes nothing.
199func Review(cur realm, id, milestone int, accept bool, reason string) {
200 voter := unsafe.PreviousRealm().Address()
201 out, pay, err := program.Review(voter, id, milestone, accept, reason, runtime.ChainHeight(), Balance())
202 if err != nil {
203 panic(err)
204 }
205 chain.Emit("Review", "id", strconv.Itoa(id), "milestone", strconv.Itoa(milestone),
206 "voter", voter.String(), "accept", strconv.FormatBool(accept), "outcome", out.String())
207 if pay == nil {
208 return
209 }
210 banker.NewBanker(banker.BankerTypeRealmSend, cur).
211 SendCoins(Address(), pay.To, chain.NewCoins(chain.NewCoin(Denom, pay.Amount)))
212 chain.Emit("Release", "id", strconv.Itoa(id), "milestone", strconv.Itoa(milestone),
213 "to", pay.To.String(), "amount", strconv.FormatInt(pay.Amount, 10))
214}