campaigns.gno
10.70 Kb · 373 lines
1// Package campaigns is Guildhall's escrow for many-hands tasks: a fixed
2// reward per claim, a fixed number of slots, and one reviewer approval per
3// claim. "10 GNOT each to the first 100 people who write about X" is one
4// campaign holding 1000 GNOT.
5//
6// Each approved claim pays the claimant and writes a reputation record to
7// gno.land/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/guildhall/reputation
8// at ClaimShareBps of a normal record's weight, vouched for by the reviewer
9// who approved it. Small, repeatable tasks therefore count for something
10// without outweighing reviewed bounty work.
11//
12// The rules live in gno.land/p/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/guildhall/campaign.
13// This realm holds the funds, authenticates callers, moves coins, and
14// renders the campaigns.
15package campaigns
16
17import (
18 "chain"
19 "chain/banker"
20 "chain/runtime/unsafe"
21 "strconv"
22 "strings"
23 "time"
24 "unicode/utf8"
25
26 "gno.land/p/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/guildhall/campaign/v0"
27 "gno.land/p/nt/avl/v0"
28 "gno.land/p/nt/ownable/v0"
29 "gno.land/p/nt/ufmt/v0"
30 "gno.land/r/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/guildhall/reputation"
31 "gno.land/r/nt/grc20reg/v0"
32)
33
34const (
35 // ClaimShareBps is the weight of a claim's reputation record relative to
36 // a fully approved bounty milestone (10000).
37 ClaimShareBps = 500
38
39 maxDeadlineDays = 365
40)
41
42var (
43 campaigns avl.Tree // padded id -> *campaign.Campaign
44 lastID uint64
45 byUser avl.Tree // address -> *avl.Tree(padded id -> struct{})
46 statusCount = map[campaign.Status]int{}
47 escrowed avl.Tree // denom key -> int64 currently held
48
49 council *ownable.Ownable
50 paused bool
51)
52
53// InitialCouncil owns this namespace and is the only key that can deploy
54// here. It can pause new campaigns until TransferCouncil hands the seat on.
55const InitialCouncil address = "g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm"
56
57func init() {
58 council = ownable.NewWithAddress(InitialCouncil)
59}
60
61// ---------------------------------------------------------------------------
62// Posting
63
64// PostCampaign posts a campaign funded with the native coins sent along with
65// the call, which must equal reward × slots. reviewers is a comma-separated
66// address list (empty means the poster reviews alone); any one of them can
67// approve a claim. deadlineDays closes the campaign to new claims after that
68// many days (0: no deadline).
69func PostCampaign(cur realm, title, description, link, tags, reviewers string, reward int64, slots, deadlineDays int) uint64 {
70 assertNotPaused()
71 if !cur.Previous().IsUserCall() {
72 panic("campaigns: native campaigns must be posted directly by a user (maketx call with -send)")
73 }
74 sent := unsafe.OriginSend()
75 if len(sent) != 1 || sent[0].Amount <= 0 {
76 panic("campaigns: send exactly one coin denomination to fund the campaign")
77 }
78 c := newCampaign(cur, sent[0].Denom, false, title, description, link, tags, reviewers, reward, slots, deadlineDays)
79 if c.Total() != sent[0].Amount {
80 panic(ufmt.Sprintf("campaigns: %d slots × %d is %d%s but %d%s was sent",
81 c.Slots, c.Reward, c.Total(), c.Denom, sent[0].Amount, sent[0].Denom))
82 }
83 return store(c)
84}
85
86// PostCampaignGRC20 posts a campaign funded with a registered GRC20 token.
87// The caller must first approve this realm's address for reward × slots on
88// the token; this call pulls the funds into escrow.
89func PostCampaignGRC20(cur realm, tokenKey, title, description, link, tags, reviewers string, reward int64, slots, deadlineDays int) uint64 {
90 assertNotPaused()
91 if grc20reg.Get(tokenKey) == nil {
92 panic("campaigns: unknown token " + strconv.Quote(tokenKey))
93 }
94 if len(unsafe.OriginSend()) > 0 && cur.Previous().IsUserCall() {
95 panic("campaigns: do not send native coins with a GRC20 campaign")
96 }
97 c := newCampaign(cur, tokenKey, true, title, description, link, tags, reviewers, reward, slots, deadlineDays)
98 grc20reg.TransferFrom(0, cur, tokenKey, c.Poster, cur.Address(), c.Total())
99 return store(c)
100}
101
102func newCampaign(cur realm, denom string, isGRC20 bool, title, description, link, tags, reviewers string, reward int64, slots, deadlineDays int) *campaign.Campaign {
103 if deadlineDays < 0 || deadlineDays > maxDeadlineDays {
104 panic(ufmt.Sprintf("campaigns: deadline must be 0..%d days", maxDeadlineDays))
105 }
106 now := time.Now()
107 var deadline time.Time
108 if deadlineDays > 0 {
109 deadline = now.Add(time.Duration(deadlineDays) * 24 * time.Hour)
110 }
111 c, err := campaign.New(campaign.Params{
112 ID: lastID + 1,
113 Poster: cur.Previous().Address(),
114 Title: title,
115 Description: description,
116 Link: link,
117 Tags: strings.Split(tags, ","),
118 Denom: denom,
119 IsGRC20: isGRC20,
120 Reward: reward,
121 Slots: slots,
122 Reviewers: campaign.ParseAddresses(reviewers),
123 Deadline: deadline,
124 Now: now,
125 })
126 check(err)
127 return c
128}
129
130func store(c *campaign.Campaign) uint64 {
131 lastID = c.ID
132 campaigns.Set(idKey(c.ID), c)
133 statusCount[c.Status]++
134 addEscrow(c, c.Total())
135 index(c.Poster, c.ID)
136 for _, r := range c.Reviewers {
137 index(r, c.ID)
138 }
139 emit("CampaignPosted", c,
140 "poster", c.Poster.String(),
141 "denom", c.Denom,
142 "reward", strconv.FormatInt(c.Reward, 10),
143 "slots", strconv.Itoa(c.Slots))
144 return c.ID
145}
146
147// ---------------------------------------------------------------------------
148// Claims
149
150// Claim asks to be paid for doing the task once. proof is a link to the work
151// (the post, the PR, the page). It reserves a slot until a reviewer decides.
152// Returns the claim number.
153func Claim(cur realm, id uint64, proof string) int {
154 c := mustGet(id)
155 caller := cur.Previous().Address()
156 n, err := c.Claim(caller, proof, time.Now())
157 check(err)
158 index(caller, id)
159 emit("CampaignClaimed", c, "claim", strconv.Itoa(n), "claimant", caller.String())
160 return n
161}
162
163// ApproveClaim pays a pending claim. Any reviewer of the campaign.
164func ApproveClaim(cur realm, id uint64, claim int) {
165 c := mustGet(id)
166 before := c.Status
167 caller := cur.Previous().Address()
168 payouts, err := c.Approve(caller, claim, time.Now())
169 check(err)
170 settle(0, cur, c, payouts, caller)
171 moved(c, before)
172}
173
174// RejectClaim turns down a pending claim and frees its slot. Any reviewer of
175// the campaign.
176func RejectClaim(cur realm, id uint64, claim int, reason string) {
177 c := mustGet(id)
178 caller := cur.Previous().Address()
179 payouts, err := c.Reject(caller, claim, reason, time.Now())
180 check(err)
181 emit("CampaignClaimRejected", c, "claim", strconv.Itoa(claim), "reviewer", caller.String())
182 settle(0, cur, c, payouts, caller)
183}
184
185// Close stops new claims and refunds the free slots to the poster. The
186// poster can close at any time; anyone can close after the deadline. Pending
187// claims can still be approved or rejected afterwards.
188func Close(cur realm, id uint64) {
189 c := mustGet(id)
190 before := c.Status
191 payouts, err := c.Close(cur.Previous().Address(), time.Now())
192 check(err)
193 settle(0, cur, c, payouts, "")
194 moved(c, before)
195 emit("CampaignClosed", c)
196}
197
198// ---------------------------------------------------------------------------
199// Council
200
201// Council is the address that can pause new campaigns.
202func Council() address {
203 return council.Owner()
204}
205
206// TransferCouncil hands the council seat to another address. Council only.
207func TransferCouncil(cur realm, newCouncil address) {
208 if err := council.TransferOwnership(0, cur, newCouncil); err != nil {
209 panic(err.Error())
210 }
211 chain.Emit("CampaignsCouncilChanged", "council", newCouncil.String())
212}
213
214// SetPaused stops or resumes new campaigns. Existing campaigns, claims and
215// payouts are never paused. Council only.
216func SetPaused(cur realm, p bool) {
217 council.AssertOwnedBy(cur.Previous().Address())
218 paused = p
219 chain.Emit("CampaignsPaused", "paused", strconv.FormatBool(p))
220}
221
222// ---------------------------------------------------------------------------
223// Queries
224
225// Count is the number of campaigns ever posted.
226func Count() int {
227 return campaigns.Size()
228}
229
230// CountByStatus counts campaigns in a status ("open" or "closed").
231func CountByStatus(status string) int {
232 s, ok := campaign.ParseStatus(status)
233 if !ok {
234 return 0
235 }
236 return statusCount[s]
237}
238
239// Escrowed is the amount of denom (a native denom or a grc20reg key) held.
240func Escrowed(denom string) int64 {
241 if v := escrowed.Get(denom); v != nil {
242 return v.(int64)
243 }
244 return 0
245}
246
247// IsPaused reports whether new campaigns are paused.
248func IsPaused() bool {
249 return paused
250}
251
252// ---------------------------------------------------------------------------
253// Internals
254
255func settle(_ int, rlm realm, c *campaign.Campaign, payouts []campaign.Payout, reviewer address) {
256 for _, p := range payouts {
257 transfer(0, rlm, c, p.To, p.Amount)
258 addEscrow(c, -p.Amount)
259 emit("CampaignPayout", c,
260 "kind", string(p.Kind),
261 "to", p.To.String(),
262 "amount", strconv.FormatInt(p.Amount, 10),
263 "claim", strconv.Itoa(p.Claim))
264 if p.Kind != campaign.PayoutWork {
265 continue
266 }
267 cl := c.GetClaim(p.Claim)
268 reputation.AddRecord(cross(rlm),
269 p.To,
270 c.Poster,
271 []address{reviewer},
272 ClaimShareBps,
273 p.Amount,
274 c.Denom,
275 clip(c.Title, 300),
276 cl.Proof,
277 ufmt.Sprintf("c%d/%d", c.ID, p.Claim),
278 false,
279 )
280 }
281}
282
283func transfer(_ int, rlm realm, c *campaign.Campaign, to address, amount int64) {
284 if amount <= 0 {
285 return
286 }
287 if c.IsGRC20 {
288 grc20reg.Transfer(0, rlm, c.Denom, to, amount)
289 return
290 }
291 bk := banker.NewBanker(banker.BankerTypeRealmSend, rlm)
292 bk.SendCoins(rlm.Address(), to, chain.Coins{{Denom: c.Denom, Amount: amount}})
293}
294
295func mustGet(id uint64) *campaign.Campaign {
296 c := getCampaign(id)
297 if c == nil {
298 panic("campaigns: campaign " + strconv.FormatUint(id, 10) + " not found")
299 }
300 return c
301}
302
303func getCampaign(id uint64) *campaign.Campaign {
304 v := campaigns.Get(idKey(id))
305 if v == nil {
306 return nil
307 }
308 return v.(*campaign.Campaign)
309}
310
311func moved(c *campaign.Campaign, before campaign.Status) {
312 if c.Status != before {
313 statusCount[before]--
314 statusCount[c.Status]++
315 }
316}
317
318func addEscrow(c *campaign.Campaign, delta int64) {
319 cur := int64(0)
320 if v := escrowed.Get(c.Denom); v != nil {
321 cur = v.(int64)
322 }
323 escrowed.Set(c.Denom, cur+delta)
324}
325
326func index(addr address, id uint64) {
327 if addr == "" {
328 return
329 }
330 var t *avl.Tree
331 if v := byUser.Get(addr.String()); v != nil {
332 t = v.(*avl.Tree)
333 } else {
334 t = avl.NewTree()
335 byUser.Set(addr.String(), t)
336 }
337 t.Set(idKey(id), struct{}{})
338}
339
340func assertNotPaused() {
341 if paused {
342 panic("campaigns: posting is paused by the council")
343 }
344}
345
346func check(err error) {
347 if err != nil {
348 panic(err.Error())
349 }
350}
351
352func emit(event string, c *campaign.Campaign, kv ...string) {
353 attrs := append([]string{"id", strconv.FormatUint(c.ID, 10), "status", c.Status.String()}, kv...)
354 chain.Emit(event, attrs...)
355}
356
357func idKey(id uint64) string {
358 s := strconv.FormatUint(id, 10)
359 for len(s) < 12 {
360 s = "0" + s
361 }
362 return s
363}
364
365func clip(s string, n int) string {
366 if len(s) <= n {
367 return s
368 }
369 for n > 0 && !utf8.RuneStart(s[n]) {
370 n--
371 }
372 return s[:n]
373}