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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}