grant.gno
9.71 Kb · 245 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/nym-moultest123/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/nym-moultest123/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/nym-moultest123/grant/v0"
60 // Link is Path as a gnoweb route.
61 Link = "/r/nym-moultest123/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 if !cur.IsCurrent() {
111 panic("grant: spoofed realm")
112 }
113 // OriginSend is what the SIGNER attached, not what reached this realm. A
114 // realm the user called keeps those coins and could call Fund any number of
115 // times, booking each as a donation, so only a user call may read it.
116 prev := cur.Previous()
117 if !prev.IsUserCall() {
118 panic("grant: Fund must be called directly by an account, with -send")
119 }
120 from := prev.Address()
121 amount := unsafe.OriginSend().AmountOf(Denom)
122 if err := program.Fund(from, amount, runtime.ChainHeight()); err != nil {
123 panic(err)
124 }
125 chain.Emit("Fund", "from", from.String(), "amount", strconv.FormatInt(amount, 10))
126}
127
128// Apply files a grant request for the caller. milestones reads
129// "title:amount,title:amount", amounts in ugnot, paid in the order given.
130// Returns the request id.
131func Apply(cur realm, title, body, milestones string) int {
132 if !cur.IsCurrent() {
133 panic("grant: spoofed realm")
134 }
135 applicant := cur.Previous().Address()
136 return file(applicant, applicant, "", title, body, milestones)
137}
138
139// ApplyFor files a grant request whose tranches pay beneficiary rather than
140// the caller, with a reason for the detour that goes on the request page.
141//
142// This is the call that makes the board usable at all for the case it exists
143// to serve. An account with nothing in it cannot pay the gas to ask for its
144// first coins, so without someone else filing on its behalf the people who
145// most need a small grant are exactly the people who cannot ask for one.
146// Either address may then submit the proofs, and neither may vote.
147func ApplyFor(cur realm, beneficiary, reason, title, body, milestones string) int {
148 if !cur.IsCurrent() {
149 panic("grant: spoofed realm")
150 }
151 applicant := cur.Previous().Address()
152 return file(applicant, address(beneficiary), reason, title, body, milestones)
153}
154
155func file(applicant, beneficiary address, reason, title, body, milestones string) int {
156 r, err := program.ApplyFor(applicant, beneficiary, reason, title, body, milestones,
157 runtime.ChainHeight())
158 if err != nil {
159 panic(err)
160 }
161 chain.Emit("Apply", "id", strconv.Itoa(r.ID), "applicant", applicant.String(),
162 "beneficiary", r.Payee().String(), "total", strconv.FormatInt(r.Total(), 10))
163 return r.ID
164}
165
166// ProposeMember asks the board to add or remove a member. Members only.
167func ProposeMember(cur realm, addr string, add bool, body string) int {
168 if !cur.IsCurrent() {
169 panic("grant: spoofed realm")
170 }
171 proposer := cur.Previous().Address()
172 r, err := program.Board.SubmitMemberChange(proposer, address(addr), add, body, runtime.ChainHeight())
173 if err != nil {
174 panic(err)
175 }
176 chain.Emit("ProposeMember", "id", strconv.Itoa(r.ID), "subject", addr,
177 "add", strconv.FormatBool(add))
178 return r.ID
179}
180
181// Retract pulls the caller's own request before it is decided.
182func Retract(cur realm, id int) {
183 if !cur.IsCurrent() {
184 panic("grant: spoofed realm")
185 }
186 caller := cur.Previous().Address()
187 if err := program.Board.Withdraw(caller, id, runtime.ChainHeight()); err != nil {
188 panic(err)
189 }
190 chain.Emit("Retract", "id", strconv.Itoa(id))
191}
192
193// Vote records a member's ballot. The reason is stored and rendered next to
194// the vote: it is the only part of a decision that tells an applicant what to
195// do about it.
196func Vote(cur realm, id int, approve bool, reason string) {
197 if !cur.IsCurrent() {
198 panic("grant: spoofed realm")
199 }
200 voter := cur.Previous().Address()
201 st, err := program.Board.Vote(voter, id, approve, reason, runtime.ChainHeight())
202 if err != nil {
203 panic(err)
204 }
205 chain.Emit("Vote", "id", strconv.Itoa(id), "voter", voter.String(),
206 "approve", strconv.FormatBool(approve), "status", st.String())
207}
208
209// SubmitProof offers evidence for the next milestone of an approved grant.
210// kind is "url", "hash" or "text"; ref is the link, digest or statement.
211func SubmitProof(cur realm, id, milestone int, kind, ref, note string) {
212 if !cur.IsCurrent() {
213 panic("grant: spoofed realm")
214 }
215 caller := cur.Previous().Address()
216 p := grants.Proof{Kind: kind, Ref: ref, Note: note, Height: runtime.ChainHeight()}
217 if err := program.Board.SubmitProof(caller, id, milestone, p); err != nil {
218 panic(err)
219 }
220 chain.Emit("SubmitProof", "id", strconv.Itoa(id),
221 "milestone", strconv.Itoa(milestone), "kind", kind)
222}
223
224// Review records a member's verdict on the proof under review. The verdict
225// that carries also pays the tranche, in this same transaction. A verdict the
226// treasury cannot cover is refused outright and changes nothing.
227func Review(cur realm, id, milestone int, accept bool, reason string) {
228 if !cur.IsCurrent() {
229 panic("grant: spoofed realm")
230 }
231 voter := cur.Previous().Address()
232 out, pay, err := program.Review(voter, id, milestone, accept, reason, runtime.ChainHeight(), Balance())
233 if err != nil {
234 panic(err)
235 }
236 chain.Emit("Review", "id", strconv.Itoa(id), "milestone", strconv.Itoa(milestone),
237 "voter", voter.String(), "accept", strconv.FormatBool(accept), "outcome", out.String())
238 if pay == nil {
239 return
240 }
241 banker.NewBanker(banker.BankerTypeRealmSend, cur).
242 SendCoins(Address(), pay.To, chain.NewCoins(chain.NewCoin(Denom, pay.Amount)))
243 chain.Emit("Release", "id", strconv.Itoa(id), "milestone", strconv.Itoa(milestone),
244 "to", pay.To.String(), "amount", strconv.FormatInt(pay.Amount, 10))
245}