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

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}