bounty_panel.gno
31.98 Kb · 910 lines
1// Realm bounty_panel is a public bounty board whose resolution
2// authority is separated from its funding authority.
3//
4// A funder escrows GNOT behind a bounty and, AT CREATION, names a panel
5// of resolvers and an M-of-N threshold. Contributors submit work
6// on-chain during a submission window. After that window closes the
7// panel votes on the competing submissions, and the first submission to
8// reach M votes wins the escrow. Neither the panel nor the threshold can
9// change after creation.
10//
11// WHY THIS EXISTS (see DISCOVERY.md): the sibling realm `grants` already
12// implements escrowed rewards, on-chain submissions, restricted award
13// and a permissionless refund valve — but there the resolver IS the
14// funder. On a public bounty with open submissions that is the wrong
15// trust model: the deciding party has a financial interest in the
16// outcome and sees every submission before deciding. This realm exists
17// for exactly that delta and reuses everything else.
18//
19// COMPOSITION: all balance accounting is delegated to feeledger, all
20// coin movement to coinio, and all free-text render output to the
21// ecosystem sanitizer p/nt/markdown/sanitize/v0. This realm owns only
22// the bounty state machine: bounty records, panels, submissions, votes,
23// open-escrow total, deadlines, and roles.
24//
25// LIFECYCLE (terminal states are frozen; one transition per bounty):
26//
27// CreateBounty (EOA + -send) : escrow -> openTotal, status Open;
28// panel + threshold + fee bps all
29// SNAPSHOTTED at creation
30// Submit (also re-submit) : while Open and height <
31// submitDeadline; keyed by the caller's
32// own address; funder and panel barred
33// Vote (panel only) : while Open and submitDeadline <=
34// height < resolveDeadline; one live
35// vote per resolver, changeable until
36// the threshold is reached; the Mth vote
37// for a submission awards the bounty
38// ATOMICALLY (Open -> Awarded)
39// CancelBounty (funder only) : Open -> Cancelled, fee-free refund —
40// ONLY while no submission exists
41// ExpireBounty (ANYONE) : Open -> Expired once height >=
42// resolveDeadline + ExpiryGraceBlocks;
43// fee-free refund to the funder — the
44// permissionless valve against a panel
45// that never resolves (but see THE ONE
46// CAVEAT below)
47// Claim / ClaimAll (anyone) : pays out the caller's own ledger
48// balance (winnings and refunds)
49// WithdrawFees (fee recipient): pays out the fee pot
50//
51// WHY THE WINDOWS DO NOT OVERLAP: submissions close at submitDeadline
52// and voting opens at the same height. A resolver therefore votes only
53// on content that can no longer change, which removes the bait-and-
54// switch where a submission collects votes and is then edited. It also
55// means no submission can be added in response to the votes already
56// cast.
57//
58// THE ONE CAVEAT ON THE EXPIRY VALVE, stated rather than glossed: both
59// ways out of an Open bounty — award and refund — credit the shared
60// feeledger, so both fail while that ledger is saturated at the int64
61// boundary, and the escrow is temporarily immovable in BOTH directions
62// until some account claims down. Nothing is lost and the valve works
63// again as soon as the ledger has headroom (this is exercised in
64// TestSaturatedAwardCannotTrapFunds). The state requires liabilities
65// within ~50 of 2^63-1 ugnot, which exceeds the real GNOT supply by
66// orders of magnitude and is unreachable absent a chain-level minting
67// bug, since every credit is backed by an escrowed -send. So: the
68// valve makes fund-trapping impossible under any reachable condition,
69// which is a weaker claim than "impossible" and is the true one.
70//
71// WHY CANCEL IS RESTRICTED: in `grants` the creator may cancel at any
72// time while open. Here, once a single contributor has submitted work,
73// the funder can no longer unilaterally reclaim the escrow — only the
74// panel (by awarding) or the expiry valve (after the resolution window)
75// can end the bounty. This is the concrete anti-harvest guarantee that
76// a public bounty needs and a grant programme does not.
77//
78// AUTHORIZATION: every identity is derived from the crossing
79// entrypoint's cur.Previous().Address() — no function takes a caller
80// identity as a parameter. Submissions, votes and claimable balances
81// are keyed by that runtime-derived address, so altering another user's
82// submission, casting another resolver's vote, or claiming another
83// user's winnings is impossible by construction.
84//
85// PANEL INTEGRITY, fixed at creation and immutable thereafter: the
86// panel is non-empty, free of duplicates, every member is a valid
87// bech32 address, and 1 <= threshold <= panelSize. Panel members may
88// not submit work, so a resolver cannot vote for their own submission.
89// The funder MAY be a panel member — barring them would be
90// unenforceable theatre (a funder can always name an address they
91// control), and the panel is public on-chain from creation, so a
92// self-resolved bounty is visible to contributors BEFORE they spend
93// effort. Disclosure beats a prohibition that cannot be enforced.
94//
95// FEE MODEL: fee = floor(amount * bps / 10000), rounding favors the
96// winner; no minimum fee; bps snapshotted into the bounty at creation,
97// so SetFeeBps affects future bounties only (closes the award-time
98// admin race), and CreateBounty takes the caller's own maxFeeBps
99// ceiling, rejecting creation if the live fee exceeds what the funder
100// signed for (closes the creation-time race); hard compile-time cap
101// MaxFeeBps (10%); refunds (cancel/expire) are always fee-free.
102//
103// MONETARY INVARIANT (conservation): let H be ugnot held at this
104// realm's address, B = openTotal (Σ amount over Open bounties), U the
105// ledger's claimable balances, F the fee pot, S >= 0 out-of-band
106// surplus:
107//
108// H == B + U + F + S
109//
110// Every transition moves value between exactly two terms inside one
111// transaction: CreateBounty raises H and B together (coinio.Receive is
112// the receipt-guaranteed shape); award/cancel/expire move amount from B
113// into U+F with feeledger guaranteeing credited + fee == amount; claims
114// and fee withdrawal debit the ledger before coinio.Payout moves the
115// identical amount out (checks-effects-interactions); any panic aborts
116// the whole transaction; this realm never issues or removes coins.
117// Surplus is recoverable only via SweepDenom (fee recipient), which
118// reserves Liabilities() = B + U + F.
119//
120// ONLY GNOT: CreateBounty rejects any envelope that is not exactly one
121// positive ugnot coin (coinio.Receive). Every other entrypoint rejects
122// attached coins outright rather than converting them to sweepable
123// surplus.
124package bounty_panel
125
126import (
127 "chain"
128 "chain/runtime"
129 "chain/runtime/unsafe"
130 "strconv"
131 "strings"
132
133 "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/coinio"
134 "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/feeledger"
135 "gno.land/p/nt/avl/v0"
136 "gno.land/p/nt/markdown/sanitize/v0"
137)
138
139// RealmPath is this realm's own path, used to build Render links.
140const RealmPath = "/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/bounty_panel"
141
142// Denom is the only asset this realm accepts.
143const Denom = "ugnot"
144
145// MaxFeeBps is the hard protocol-fee cap: 1000 bps = 10%.
146const MaxFeeBps = int64(1000)
147
148// Bounty status values.
149const (
150 StatusOpen = "open"
151 StatusAwarded = "awarded"
152 StatusCancelled = "cancelled"
153 StatusExpired = "expired"
154)
155
156// Input bounds.
157const (
158 MaxTitleLen = 80
159 MaxDescLen = 2000
160
161 // MaxURILen bounds a submission's content reference. Submissions
162 // carry a REFERENCE (a URL or content hash), not the work itself —
163 // the chain cannot judge quality, and storing bulk content would
164 // push an unbounded cost onto every future reader of this realm.
165 MaxURILen = 500
166
167 // MaxPanelSize bounds panel parsing, storage and Render cost.
168 MaxPanelSize = int64(16)
169
170 // MaxSubmissions bounds per-bounty state growth. Each submission
171 // also costs its submitter a storage deposit, so this is a ceiling
172 // on a cost that is already borne by the party creating it.
173 MaxSubmissions = int64(500)
174
175 // MinDurationBlocks / MaxDurationBlocks bound each configurable
176 // window (~5s blocks: 1 block to ~580 days).
177 MinDurationBlocks = int64(1)
178 MaxDurationBlocks = int64(10_000_000)
179
180 // ExpiryGraceBlocks after the resolution deadline, an Open bounty
181 // becomes expirable by anyone (~8 minutes at 5s blocks — short
182 // because this is a testnet deployment; a production fork would
183 // raise it). The grace exists so that "the panel may still vote"
184 // and "anyone may expire" are never simultaneously true.
185 ExpiryGraceBlocks = int64(100)
186
187 // MaxRenderRows bounds Render output. Render is reachable by any
188 // viewer through gnoweb and vm/qrender, so its cost lands on third
189 // parties rather than on whoever grew the state.
190 MaxRenderRows = 20
191)
192
193type submission struct {
194 uri string // content reference (URL or hash)
195 height int64 // block height of the latest (re)submission
196 votes int64 // live panel votes currently naming this submission
197}
198
199type bounty struct {
200 id int64
201 funder address
202 title string
203 description string
204 amount int64
205 feeBps int64 // snapshotted at creation, charged at award
206
207 panel *avl.Tree // resolver address string -> struct{}{}
208 panelSize int64
209 threshold int64 // votes required to award (1 <= threshold <= panelSize)
210
211 submitDeadline int64 // submissions close here; voting opens here
212 resolveDeadline int64 // voting closes here
213
214 status string
215 winner address // set iff status == StatusAwarded
216
217 subs *avl.Tree // submitter address string -> *submission
218 numSubs int64
219 ballots *avl.Tree // resolver address string -> submitter address string
220}
221
222var (
223 admin address // may set fee, fee recipient, successor admin
224 feeRecipient address // may withdraw fees and sweep surplus
225 feeBps int64 // fee snapshotted into NEW bounties
226
227 self address // this realm's address, captured at deploy
228 nextID int64
229 openTotal int64 // == Σ amount over bounties with status Open
230
231 bounties = avl.NewTree() // padID(id) -> *bounty
232 ledger = feeledger.MustNew(MaxFeeBps)
233)
234
235func init() {
236 admin = unsafe.OriginCaller()
237 feeRecipient = admin
238 self = unsafe.CurrentRealm().Address()
239}
240
241// rejectStraySend aborts when coins are attached to a non-payable call.
242// This realm does hold funds, so a stray send would not be lost outright
243// — it would become sweepable surplus belonging to the fee recipient,
244// silently converting a user's coins into protocol revenue. Aborting
245// reverts the transfer to the sender instead. Guarded on IsUserCall, not
246// IsUser, because a MsgRun ephemeral can consume the OriginSend envelope
247// before forwarding control.
248//
249// SCOPE, precisely: the guard reads the ORIGINATING transaction's
250// envelope, so it only inspects direct EOA calls. For a realm-routed
251// call the -send envelope is delivered to the INTERMEDIARY realm's
252// address, so there is nothing at this realm to reject and the guard
253// deliberately fails open. That is not a hole: an intermediary that
254// separately sends coins to this realm's address is making an ordinary
255// transfer, which no guard in any entrypoint could intercept, and
256// which lands as surplus recoverable through SweepDenom. Coins can
257// only become ESCROW through CreateBounty.
258func rejectStraySend(cur realm) {
259 if cur.Previous().IsUserCall() && len(unsafe.OriginSend()) > 0 {
260 panic("this entrypoint does not accept coins")
261 }
262}
263
264// CreateBounty escrows the attached GNOT as a new open bounty and
265// returns its id. Only direct EOA calls with -send are accepted.
266//
267// panelCSV is a comma-separated list of resolver addresses; threshold is
268// how many of them must name the same submission for it to win.
269// Submissions are accepted for submitBlocks from now, after which the
270// panel has resolveBlocks to decide.
271//
272// The current protocol fee is snapshotted into the bounty and must not
273// exceed maxFeeBps, the ceiling the caller signed for; pass MaxFeeBps to
274// accept any legal fee.
275func CreateBounty(cur realm, title, description, panelCSV string, threshold, submitBlocks, resolveBlocks, maxFeeBps int64) int64 {
276 funder, amount := coinio.Receive(0, cur, Denom)
277 if feeBps > maxFeeBps {
278 panic("current fee " + itoa(feeBps) + " bps exceeds the caller's maximum " + itoa(maxFeeBps))
279 }
280 assertValidTitle(title)
281 if len(description) == 0 || len(description) > MaxDescLen {
282 panic("description must be 1-" + strconv.Itoa(MaxDescLen) + " bytes")
283 }
284 assertDuration("submitBlocks", submitBlocks)
285 assertDuration("resolveBlocks", resolveBlocks)
286
287 panel, panelSize := parsePanel(panelCSV)
288 if threshold < 1 || threshold > panelSize {
289 panic("threshold must be in [1, panel size " + itoa(panelSize) + "]")
290 }
291
292 newOpenTotal, ok := checkedAdd(openTotal, amount)
293 if !ok {
294 panic("open escrow overflow")
295 }
296
297 now := runtime.ChainHeight()
298 nextID++
299 b := &bounty{
300 id: nextID,
301 funder: funder,
302 title: title,
303 description: description,
304 amount: amount,
305 feeBps: feeBps,
306 panel: panel,
307 panelSize: panelSize,
308 threshold: threshold,
309 submitDeadline: now + submitBlocks,
310 resolveDeadline: now + submitBlocks + resolveBlocks,
311 status: StatusOpen,
312 subs: avl.NewTree(),
313 ballots: avl.NewTree(),
314 }
315 bounties.Set(padID(b.id), b)
316 openTotal = newOpenTotal
317
318 chain.Emit("BountyCreated",
319 "id", itoa(b.id),
320 "funder", funder.String(),
321 "amount", itoa(amount),
322 "feeBps", itoa(b.feeBps),
323 "panelSize", itoa(panelSize),
324 "threshold", itoa(threshold),
325 "submitDeadline", itoa(b.submitDeadline),
326 "resolveDeadline", itoa(b.resolveDeadline),
327 )
328 return b.id
329}
330
331// Submit records (or replaces) the caller's submission to an open bounty
332// before its submission deadline. One submission per address per bounty
333// — re-submitting replaces the caller's own reference only. The funder
334// and every panel member are barred, so no resolver can vote for their
335// own work.
336func Submit(cur realm, id int64, uri string) {
337 rejectStraySend(cur)
338 submitter := cur.Previous().Address()
339 b := mustGetBounty(id)
340 if b.status != StatusOpen {
341 panic("bounty is not open")
342 }
343 if runtime.ChainHeight() >= b.submitDeadline {
344 panic("submission window has closed")
345 }
346 if submitter == b.funder {
347 panic("the funder cannot submit to their own bounty")
348 }
349 if b.panel.Has(submitter.String()) {
350 panic("a panel resolver cannot submit to a bounty they judge")
351 }
352 if len(uri) == 0 || len(uri) > MaxURILen {
353 panic("uri must be 1-" + strconv.Itoa(MaxURILen) + " bytes")
354 }
355
356 key := submitter.String()
357 if v := b.subs.Get(key); v != nil {
358 s := v.(*submission)
359 s.uri = uri
360 s.height = runtime.ChainHeight()
361 } else {
362 if b.numSubs >= MaxSubmissions {
363 panic("bounty has reached its submission cap")
364 }
365 b.numSubs++
366 b.subs.Set(key, &submission{uri: uri, height: runtime.ChainHeight()})
367 }
368
369 chain.Emit("Submitted", "id", itoa(id), "submitter", key)
370}
371
372// Vote casts (or changes) the calling resolver's vote for one of the
373// bounty's submissions. Only panel members may vote, and only after the
374// submission window has closed and before the resolution deadline. A
375// resolver holds exactly one live vote, changeable until the threshold
376// is reached.
377//
378// The vote that brings a submission to the threshold awards the bounty
379// in the same transaction: the escrow leaves the open pool and is
380// credited to the winner's claimable balance at the fee snapshotted at
381// creation. Terminal.
382func Vote(cur realm, id int64, candidate address) {
383 rejectStraySend(cur)
384 resolver := cur.Previous().Address()
385 b := mustGetBounty(id)
386 if b.status != StatusOpen {
387 panic("bounty is not open")
388 }
389 if !b.panel.Has(resolver.String()) {
390 panic("only a panel resolver may vote")
391 }
392 now := runtime.ChainHeight()
393 if now < b.submitDeadline {
394 panic("voting opens when the submission window closes")
395 }
396 if now >= b.resolveDeadline {
397 panic("resolution window has closed")
398 }
399
400 candKey := candidate.String()
401 cv := b.subs.Get(candKey)
402 if cv == nil {
403 panic("candidate must have submitted to this bounty")
404 }
405 cand := cv.(*submission)
406
407 rKey := resolver.String()
408 if prev := b.ballots.Get(rKey); prev != nil {
409 prevKey := prev.(string)
410 if prevKey == candKey {
411 panic("already voted for this submission")
412 }
413 // Withdraw the resolver's previous vote before recording the new
414 // one, so the per-submission counts always sum to the number of
415 // live ballots.
416 pv := b.subs.Get(prevKey)
417 pv.(*submission).votes--
418 }
419 b.ballots.Set(rKey, candKey)
420 cand.votes++
421
422 chain.Emit("Voted",
423 "id", itoa(id),
424 "resolver", rKey,
425 "candidate", candKey,
426 "votes", itoa(cand.votes),
427 "threshold", itoa(b.threshold),
428 )
429
430 if cand.votes >= b.threshold {
431 award(b, candidate, cand.votes)
432 }
433}
434
435// award moves an Open bounty's escrow into the winner's claimable
436// balance at the snapshotted fee and finalizes the status. The caller
437// has already verified authorization, status and the threshold.
438func award(b *bounty, winner address, votes int64) {
439 credited, fee, err := ledger.Deposit(winner.String(), b.amount, b.feeBps)
440 if err != nil {
441 panic(err)
442 }
443 openTotal -= b.amount // >= 0: openTotal == Σ open amounts >= b.amount
444 b.status = StatusAwarded
445 b.winner = winner
446
447 chain.Emit("BountyAwarded",
448 "id", itoa(b.id),
449 "winner", winner.String(),
450 "credited", itoa(credited),
451 "fee", itoa(fee),
452 "votes", itoa(votes),
453 )
454}
455
456// CancelBounty closes an open bounty and refunds its escrow to the
457// funder, fee-free. Only the funder may cancel, and ONLY while no
458// contributor has submitted: once work exists, the funder cannot
459// unilaterally reclaim the escrow. Terminal.
460func CancelBounty(cur realm, id int64) {
461 rejectStraySend(cur)
462 caller := cur.Previous().Address()
463 b := mustGetBounty(id)
464 if caller != b.funder {
465 panic("only the bounty funder may cancel")
466 }
467 if b.status != StatusOpen {
468 panic("bounty is not open")
469 }
470 if b.numSubs > 0 {
471 panic("cannot cancel a bounty that has submissions; it must be resolved or expire")
472 }
473 refundOpen(b, StatusCancelled)
474 chain.Emit("BountyCancelled", "id", itoa(id), "funder", b.funder.String(), "amount", itoa(b.amount))
475}
476
477// ExpireBounty closes an open bounty whose resolution deadline passed
478// more than ExpiryGraceBlocks ago, refunding the funder fee-free. ANYONE
479// may call it — this is the permissionless valve that guarantees escrow
480// can never be trapped by an inactive or deadlocked panel. Terminal.
481func ExpireBounty(cur realm, id int64) {
482 rejectStraySend(cur)
483 b := mustGetBounty(id)
484 if b.status != StatusOpen {
485 panic("bounty is not open")
486 }
487 if runtime.ChainHeight() < b.resolveDeadline+ExpiryGraceBlocks {
488 panic("bounty is not expirable yet")
489 }
490 refundOpen(b, StatusExpired)
491 chain.Emit("BountyExpired", "id", itoa(b.id), "funder", b.funder.String(), "amount", itoa(b.amount))
492}
493
494// refundOpen moves an Open bounty's escrow into the funder's claimable
495// balance fee-free and finalizes the status. Callers have already
496// verified authorization and status.
497func refundOpen(b *bounty, terminal string) {
498 if _, _, err := ledger.Deposit(b.funder.String(), b.amount, 0); err != nil {
499 panic(err)
500 }
501 openTotal -= b.amount
502 b.status = terminal
503}
504
505// Claim sends amount ugnot of the caller's claimable balance (winnings
506// and refunds) back to the caller.
507func Claim(cur realm, amount int64) {
508 rejectStraySend(cur)
509 caller := cur.Previous().Address()
510 if err := ledger.Withdraw(caller.String(), amount); err != nil {
511 panic(err)
512 }
513 coinio.Payout(0, cur, caller, Denom, amount)
514 chain.Emit("Claim", "to", caller.String(), "amount", itoa(amount))
515}
516
517// ClaimAll sends the caller's entire claimable balance back to the
518// caller. Fails if there is nothing to claim.
519func ClaimAll(cur realm) {
520 rejectStraySend(cur)
521 caller := cur.Previous().Address()
522 amount, err := ledger.WithdrawAll(caller.String())
523 if err != nil {
524 panic(err)
525 }
526 if amount == 0 {
527 panic("nothing to claim")
528 }
529 coinio.Payout(0, cur, caller, Denom, amount)
530 chain.Emit("Claim", "to", caller.String(), "amount", itoa(amount))
531}
532
533// WithdrawFees sends the accrued fee pot to the fee recipient. Only the
534// fee recipient may call it.
535func WithdrawFees(cur realm) {
536 rejectStraySend(cur)
537 caller := cur.Previous().Address()
538 if caller != feeRecipient {
539 panic("only the fee recipient may withdraw fees")
540 }
541 if ledger.FeesAccrued() == 0 {
542 panic("no fees accrued")
543 }
544 amount := ledger.WithdrawFees()
545 coinio.Payout(0, cur, feeRecipient, Denom, amount)
546 chain.Emit("FeesWithdrawn", "to", feeRecipient.String(), "amount", itoa(amount))
547}
548
549// SweepDenom sends the surplus of a single denomination to the fee
550// recipient. For ugnot only the excess over Liabilities() moves; other
551// denoms move wholly. Only the fee recipient may call it.
552func SweepDenom(cur realm, denom string) {
553 rejectStraySend(cur)
554 caller := cur.Previous().Address()
555 if caller != feeRecipient {
556 panic("only the fee recipient may sweep surplus")
557 }
558 reserve := int64(0)
559 if denom == Denom {
560 reserve = Liabilities()
561 }
562 swept := coinio.Sweep(0, cur, feeRecipient, denom, reserve)
563 chain.Emit("SurplusSwept", "to", feeRecipient.String(), "coins", itoa(swept)+denom)
564}
565
566// SetFeeBps sets the protocol fee snapshotted into FUTURE bounties at
567// creation. Existing bounties keep the fee they were created under.
568// Admin only; bounded by [0, MaxFeeBps].
569func SetFeeBps(cur realm, bps int64) {
570 rejectStraySend(cur)
571 assertAdmin(cur.Previous().Address())
572 if bps < 0 || bps > MaxFeeBps {
573 panic("fee bps out of range [0, " + itoa(MaxFeeBps) + "]")
574 }
575 old := feeBps
576 feeBps = bps
577 chain.Emit("FeeBpsChanged", "old", itoa(old), "new", itoa(bps))
578}
579
580// SetFeeRecipient re-points the fee/surplus role, including the pot
581// accrued so far. Admin only; zero address rejected.
582func SetFeeRecipient(cur realm, next address) {
583 rejectStraySend(cur)
584 assertAdmin(cur.Previous().Address())
585 var zero address
586 if next == zero {
587 panic("empty fee recipient")
588 }
589 old := feeRecipient
590 feeRecipient = next
591 chain.Emit("FeeRecipientChanged", "old", old.String(), "new", next.String())
592}
593
594// TransferAdmin hands the admin role to next. Admin only; zero address
595// rejected. One-step (documented trade-off, matching the siblings).
596func TransferAdmin(cur realm, next address) {
597 rejectStraySend(cur)
598 assertAdmin(cur.Previous().Address())
599 var zero address
600 if next == zero {
601 panic("empty admin address")
602 }
603 admin = next
604 chain.Emit("AdminTransferred", "newAdmin", next.String())
605}
606
607// --- read-only views ---
608
609// BountyInfo returns a bounty's scalar fields by value.
610func BountyInfo(id int64) (funder address, title string, amount, feeBps, submitDeadline, resolveDeadline int64, status string, winner address, numSubmissions, panelSize, threshold int64) {
611 b := mustGetBounty(id)
612 return b.funder, b.title, b.amount, b.feeBps, b.submitDeadline, b.resolveDeadline, b.status, b.winner, b.numSubs, b.panelSize, b.threshold
613}
614
615// Description returns a bounty's raw description text.
616func Description(id int64) string { return mustGetBounty(id).description }
617
618// SubmissionOf returns addr's content reference, submission height and
619// current vote count for a bounty, with ok reporting whether a
620// submission exists.
621func SubmissionOf(id int64, addr address) (uri string, height, votes int64, ok bool) {
622 b := mustGetBounty(id)
623 v := b.subs.Get(addr.String())
624 if v == nil {
625 return "", 0, 0, false
626 }
627 s := v.(*submission)
628 return s.uri, s.height, s.votes, true
629}
630
631// IsPanelMember reports whether addr may vote on a bounty.
632func IsPanelMember(id int64, addr address) bool {
633 return mustGetBounty(id).panel.Has(addr.String())
634}
635
636// Panel returns a bounty's resolver addresses as a comma-separated
637// list, in sorted order.
638func Panel(id int64) string {
639 b := mustGetBounty(id)
640 out := ""
641 b.panel.Iterate("", "", func(key string, _ any) bool {
642 if out != "" {
643 out += ","
644 }
645 out += key
646 return false
647 })
648 return out
649}
650
651// VoteOf returns the submission a resolver currently votes for, with ok
652// reporting whether that resolver has voted at all.
653func VoteOf(id int64, resolver address) (candidate string, ok bool) {
654 b := mustGetBounty(id)
655 v := b.ballots.Get(resolver.String())
656 if v == nil {
657 return "", false
658 }
659 return v.(string), true
660}
661
662// VotesCast returns how many resolvers currently hold a live vote.
663func VotesCast(id int64) int64 { return int64(mustGetBounty(id).ballots.Size()) }
664
665// Admin returns the current admin.
666func Admin() address { return admin }
667
668// FeeRecipient returns who may withdraw fees and sweep surplus.
669func FeeRecipient() address { return feeRecipient }
670
671// FeeBps returns the fee that will be snapshotted into newly created
672// bounties (existing bounties keep their own snapshot).
673func FeeBps() int64 { return feeBps }
674
675// FeeOn previews the fee and net payout for a bounty of amount at the
676// CURRENT FeeBps.
677func FeeOn(amount int64) (fee, credited int64) {
678 f, err := feeledger.FeeFor(amount, feeBps)
679 if err != nil {
680 panic(err)
681 }
682 return f, amount - f
683}
684
685// NumBounties returns how many bounties have ever been created.
686func NumBounties() int64 { return nextID }
687
688// OpenTotal returns the escrow held by open bounties (the B term).
689func OpenTotal() int64 { return openTotal }
690
691// BalanceOf returns addr's claimable balance (winnings + refunds).
692func BalanceOf(addr address) int64 { return ledger.BalanceOf(addr.String()) }
693
694// UsersTotal returns the sum of all claimable balances (the U term).
695func UsersTotal() int64 { return ledger.UsersTotal() }
696
697// FeesAccrued returns the fee pot (the F term).
698func FeesAccrued() int64 { return ledger.FeesAccrued() }
699
700// Liabilities returns everything this realm owes:
701// openTotal + UsersTotal + FeesAccrued.
702func Liabilities() int64 { return openTotal + ledger.Liabilities() }
703
704// Held returns the ugnot actually held at the realm address (H).
705func Held() int64 { return coinio.HeldAt(self, Denom) }
706
707// Surplus returns Held() - Liabilities() (the S term).
708func Surplus() int64 { return Held() - Liabilities() }
709
710// Address returns this realm's address (the escrow target).
711func Address() address { return self }
712
713// Height returns the current chain height (deadline arithmetic aid).
714func Height() int64 { return runtime.ChainHeight() }
715
716// Render shows the board at "" and a bounty detail at "<id>". Free text
717// (titles are charset-restricted; descriptions and URIs are not) passes
718// through the ecosystem sanitizer before hitting markdown.
719func Render(path string) string {
720 if path != "" {
721 return renderBounty(path)
722 }
723 held := Held()
724 liab := Liabilities()
725 status := "OK"
726 if held < liab {
727 status = "VIOLATED"
728 }
729 out := "# Bounty panel\n\n"
730 out += "Escrowed GNOT bounties resolved by a panel named at creation, not by the funder.\n\n"
731 out += "## Configuration\n\n"
732 out += "- fee for new bounties: " + itoa(feeBps) + " bps (cap " + itoa(MaxFeeBps) +
733 " bps; each bounty keeps the fee snapshotted at its creation)\n"
734 out += "- fee recipient: " + feeRecipient.String() + "\n"
735 out += "- admin: " + admin.String() + "\n\n"
736 out += "## Accounting (H == B + U + F + S)\n\n"
737 out += "- open escrow (B): " + itoa(openTotal) + Denom + "\n"
738 out += "- claimable (U): " + itoa(ledger.UsersTotal()) + Denom + "\n"
739 out += "- fees accrued (F): " + itoa(ledger.FeesAccrued()) + Denom + "\n"
740 out += "- held (H): " + itoa(held) + Denom + "\n"
741 out += "- conservation: " + status + "\n\n"
742 out += "## Latest bounties\n\n"
743 if nextID == 0 {
744 out += "No bounties yet.\n"
745 return out
746 }
747 shown := 0
748 bounties.ReverseIterate("", "", func(_ string, v any) bool {
749 b := v.(*bounty)
750 out += "- [#" + itoa(b.id) + "](" + RealmPath + ":" + itoa(b.id) + ") [" +
751 b.status + "] " + b.title + " — " + itoa(b.amount) + Denom +
752 " (" + itoa(b.numSubs) + " submissions, " + itoa(b.threshold) + "-of-" + itoa(b.panelSize) + ")\n"
753 shown++
754 return shown >= MaxRenderRows
755 })
756 if int64(shown) < nextID {
757 out += "\n> [!NOTE]\n> Showing the " + strconv.Itoa(shown) + " most recent of " +
758 itoa(nextID) + ". Use BountyInfo(id) for any specific bounty.\n"
759 }
760 return out
761}
762
763func renderBounty(path string) string {
764 id, err := strconv.ParseInt(path, 10, 64)
765 if err != nil {
766 return "> [!WARNING]\n> invalid bounty id\n"
767 }
768 v := bounties.Get(padID(id))
769 if v == nil {
770 return "> [!WARNING]\n> unknown bounty id\n"
771 }
772 b := v.(*bounty)
773 now := runtime.ChainHeight()
774
775 out := "# Bounty #" + itoa(b.id) + ": " + b.title + "\n\n"
776 out += "- status: " + b.status + "\n"
777 out += "- funder: " + b.funder.String() + "\n"
778 out += "- amount: " + itoa(b.amount) + Denom + "\n"
779 out += "- fee (snapshot): " + itoa(b.feeBps) + " bps\n"
780 out += "- resolution: " + itoa(b.threshold) + " of " + itoa(b.panelSize) + " panel votes\n"
781 out += "- submissions close: block " + itoa(b.submitDeadline) + "\n"
782 out += "- resolution closes: block " + itoa(b.resolveDeadline) + " (now " + itoa(now) + ")\n"
783 out += "- submissions: " + itoa(b.numSubs) + "\n"
784 if b.status == StatusAwarded {
785 out += "- winner: " + b.winner.String() + "\n"
786 }
787
788 out += "\n## Description\n\n" + sanitize.InlineText(b.description) + "\n"
789
790 out += "\n## Panel\n\n"
791 b.panel.Iterate("", "", func(key string, _ any) bool {
792 out += "- " + key + "\n"
793 return false
794 })
795
796 out += "\n## Submissions\n\n"
797 if b.numSubs == 0 {
798 out += "No submissions yet.\n"
799 return out
800 }
801 shown := 0
802 b.subs.Iterate("", "", func(key string, sv any) bool {
803 s := sv.(*submission)
804 out += "- " + key + " — " + itoa(s.votes) + " vote(s), block " + itoa(s.height) +
805 "\n - " + sanitize.InlineText(s.uri) + "\n"
806 shown++
807 return shown >= MaxRenderRows
808 })
809 if int64(shown) < b.numSubs {
810 out += "\n> [!NOTE]\n> Showing " + strconv.Itoa(shown) + " of " + itoa(b.numSubs) +
811 " submissions. Use SubmissionOf(id, addr) for any specific one.\n"
812 }
813 return out
814}
815
816// --- internals ---
817
818func assertAdmin(caller address) {
819 if caller != admin {
820 panic("admin only")
821 }
822}
823
824func mustGetBounty(id int64) *bounty {
825 v := bounties.Get(padID(id))
826 if v == nil {
827 panic("unknown bounty id")
828 }
829 return v.(*bounty)
830}
831
832func assertDuration(name string, blocks int64) {
833 if blocks < MinDurationBlocks || blocks > MaxDurationBlocks {
834 panic(name + " out of range [" + itoa(MinDurationBlocks) + ", " + itoa(MaxDurationBlocks) + "]")
835 }
836}
837
838// parsePanel turns a comma-separated resolver list into a membership
839// tree. Every entry must be a valid, non-empty, non-duplicate bech32
840// address. Validity is checked here rather than trusted: a mistyped
841// resolver address would silently shrink the effective panel and could
842// make the threshold permanently unreachable, which is a fund-trapping
843// shape even with the expiry valve in place.
844func parsePanel(csv string) (*avl.Tree, int64) {
845 parts := strings.Split(csv, ",")
846 panel := avl.NewTree()
847 count := int64(0)
848 for _, p := range parts {
849 key := strings.TrimSpace(p)
850 if key == "" {
851 panic("panel contains an empty entry")
852 }
853 if !address(key).IsValid() {
854 panic("panel contains an invalid address: " + key)
855 }
856 if panel.Has(key) {
857 panic("panel contains a duplicate address: " + key)
858 }
859 count++
860 if count > MaxPanelSize {
861 panic("panel exceeds " + itoa(MaxPanelSize) + " resolvers")
862 }
863 panel.Set(key, struct{}{})
864 }
865 if count == 0 {
866 panic("panel must name at least one resolver")
867 }
868 return panel, count
869}
870
871// assertValidTitle bounds length and restricts the charset so titles are
872// list-safe in Render without escaping.
873func assertValidTitle(title string) {
874 if len(title) == 0 || len(title) > MaxTitleLen {
875 panic("title must be 1-" + strconv.Itoa(MaxTitleLen) + " characters")
876 }
877 for i := 0; i < len(title); i++ {
878 c := title[i]
879 switch {
880 case c >= 'a' && c <= 'z':
881 case c >= 'A' && c <= 'Z':
882 case c >= '0' && c <= '9':
883 case c == ' ' || c == '_' || c == '-':
884 default:
885 panic("title may only contain letters, digits, space, _ and -")
886 }
887 }
888}
889
890// padID renders an id as a fixed-width key so avl iteration order is
891// numeric order.
892func padID(id int64) string {
893 s := strconv.FormatInt(id, 10)
894 for len(s) < 12 {
895 s = "0" + s
896 }
897 return s
898}
899
900func itoa(n int64) string {
901 return strconv.FormatInt(n, 10)
902}
903
904func checkedAdd(a, b int64) (int64, bool) {
905 sum := a + b
906 if (b > 0 && sum < a) || (b < 0 && sum > a) {
907 return 0, false
908 }
909 return sum, true
910}