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

bounties.gno

15.20 Kb · 472 lines
  1// Realm bounties is a GNOT bounty board that COMPOSES the reusable
  2// accounting package gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/feeledger
  3// instead of re-implementing balance accounting.
  4//
  5// STATE OWNERSHIP (the dependency boundary):
  6//   - This realm owns the bounty state machine: bounty records (funder,
  7//     title, amount, status, winner), the sum of open escrow
  8//     (openTotal), and per-bounty funder authorization.
  9//   - feeledger owns all claimable-balance accounting: per-account
 10//     balances, the protocol-fee pot, fee rounding, overflow checks,
 11//     and withdraw arithmetic. This realm never duplicates that logic;
 12//     it only calls the ledger API and panics on its errors.
 13//
 14// LIFECYCLE:
 15//
 16//	CreateBounty (EOA + -send)  : escrow -> openTotal, status Open
 17//	Award (funder only)         : openTotal -> ledger.Deposit(winner,
 18//	                              amount, snapshot fee)
 19//	Cancel (funder only)        : openTotal -> ledger.Deposit(funder,
 20//	                              amount, 0)       [no fee on refund]
 21//	Claim / ClaimAll (anyone)   : pays out the caller's ledger balance
 22//	WithdrawFees (fee recipient): pays out the fee pot
 23//
 24// Awarded and Cancelled are terminal; a bounty transitions exactly once.
 25//
 26// FEE MODEL: fee = floor(amount * bps / 10000), rounding favors the
 27// recipient, no minimum fee, admin-settable up to the compile-time
 28// MaxFeeBps cap, accrued to a pot withdrawable by the fee recipient
 29// role. The applicable bps is SNAPSHOTTED INTO THE BOUNTY AT CREATION
 30// and charged at Award: the funder commits to the fee they saw, and a
 31// later SetFeeBps affects only bounties created afterwards (this
 32// closes the admin front-run found in the composition audit). Refunds
 33// via Cancel are always fee-free. A failed Award (e.g. ledger
 34// overflow) leaves the bounty Open — the funder can retry or Cancel.
 35//
 36// COMPOSED ACCOUNTING INVARIANT: let H be ugnot held at this realm's
 37// address, B = openTotal (application escrow), U+F the ledger's
 38// liabilities, S >= 0 out-of-band surplus. At every transaction
 39// boundary:
 40//
 41//	H == B + U + F + S
 42//
 43// Derivation: CreateBounty raises H and B equally (the IsUserCall +
 44// envelope guard is the receipt-guaranteed shape, validated live on
 45// pearl-1); Award/Cancel move amount from B into U+F within one
 46// transaction, and feeledger guarantees credited + fee == amount;
 47// Claim/WithdrawFees debit the ledger before sending the identical
 48// amount (checks-effects-interactions), lowering H and U+F equally;
 49// any panic aborts the whole transaction; this realm never issues or
 50// removes coins. The application invariant B == Σ amount(Open) is
 51// maintained in lockstep with every status transition.
 52//
 53// ONLY GNOT: CreateBounty rejects any envelope that is not exactly one
 54// positive ugnot coin. Foreign denominations force-sent to the realm
 55// sit in surplus and are recoverable via SweepDenom (fee recipient
 56// only), which never touches B, U, or F.
 57package bounties
 58
 59import (
 60	"chain"
 61	"chain/banker"
 62	"chain/runtime/unsafe"
 63	"strconv"
 64
 65	"gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/feeledger"
 66	"gno.land/p/nt/avl/v0"
 67)
 68
 69// Denom is the only asset this realm accepts.
 70const Denom = "ugnot"
 71
 72// MaxFeeBps is the hard protocol-fee cap: 1000 bps = 10%.
 73const MaxFeeBps = int64(1000)
 74
 75// Bounty status values.
 76const (
 77	StatusOpen      = "open"
 78	StatusAwarded   = "awarded"
 79	StatusCancelled = "cancelled"
 80)
 81
 82const maxTitleLen = 80
 83
 84// Bounty is one bounty record. Fields are unexported; read access goes
 85// through BountyInfo so no interior pointers escape the realm.
 86type bounty struct {
 87	id     int64
 88	funder address
 89	title  string
 90	amount int64
 91	feeBps int64 // fee policy snapshotted at creation, charged at Award
 92	status string
 93	winner address // set iff status == StatusAwarded
 94}
 95
 96var (
 97	admin        address // may set fee, fee recipient, successor admin
 98	feeRecipient address // may withdraw fees and sweep surplus
 99	feeBps       int64   // protocol fee snapshotted into new bounties
100
101	self      address // this realm's address, captured at deploy
102	nextID    int64   // next bounty id (first bounty gets id 1)
103	openTotal int64   // == Σ amount over bounties with status Open
104
105	bounties = avl.NewTree() // padID(id) -> *bounty
106	ledger   = feeledger.MustNew(MaxFeeBps)
107)
108
109func init() {
110	admin = unsafe.OriginCaller()
111	feeRecipient = admin
112	self = unsafe.CurrentRealm().Address()
113}
114
115// CreateBounty escrows the attached GNOT as a new open bounty and
116// returns its id. Only direct EOA calls with -send are accepted (the
117// receipt-guaranteed shape). The envelope must be exactly one positive
118// ugnot coin. The caller becomes the bounty's funder.
119func CreateBounty(cur realm, title string) int64 {
120	if !cur.Previous().IsUserCall() {
121		panic("bounty creation must be a direct EOA call with -send")
122	}
123	sent := unsafe.OriginSend()
124	if len(sent) != 1 || sent[0].Denom != Denom {
125		panic("send exactly one coin type: " + Denom)
126	}
127	amount := sent[0].Amount
128	if amount <= 0 {
129		panic("bounty amount must be positive")
130	}
131	assertValidTitle(title)
132
133	newOpenTotal, ok := checkedAdd(openTotal, amount)
134	if !ok {
135		panic("open escrow overflow")
136	}
137
138	funder := cur.Previous().Address()
139	nextID++
140	b := &bounty{
141		id:     nextID,
142		funder: funder,
143		title:  title,
144		amount: amount,
145		feeBps: feeBps, // snapshot: later SetFeeBps cannot change this bounty's fee
146		status: StatusOpen,
147	}
148	bounties.Set(padID(b.id), b)
149	openTotal = newOpenTotal
150
151	chain.Emit("BountyCreated",
152		"id", itoa(b.id),
153		"funder", funder.String(),
154		"amount", itoa(amount),
155		"feeBps", itoa(b.feeBps),
156	)
157	return b.id
158}
159
160// Award closes an open bounty in favor of winner: the escrowed amount
161// leaves the open pool and is credited to winner's claimable balance
162// through the ledger, charging the fee snapshotted at creation. Only
163// the bounty's funder may award it. Terminal: an awarded bounty can
164// never change again.
165func Award(cur realm, id int64, winner address) {
166	caller := cur.Previous().Address()
167	b := mustGetBounty(id)
168	if caller != b.funder {
169		panic("only the bounty funder may award")
170	}
171	if b.status != StatusOpen {
172		panic("bounty is not open")
173	}
174	var zero address
175	if winner == zero {
176		panic("empty winner address")
177	}
178
179	// Move the escrow from application state into ledger liabilities in
180	// one transaction, at the fee snapshotted when the bounty was
181	// created. feeledger validates before mutating and guarantees
182	// credited + fee == amount; an error aborts everything and leaves
183	// the bounty Open.
184	credited, fee, err := ledger.Deposit(winner.String(), b.amount, b.feeBps)
185	if err != nil {
186		panic(err)
187	}
188	openTotal -= b.amount // >= 0: openTotal == Σ open amounts >= b.amount
189	b.status = StatusAwarded
190	b.winner = winner
191
192	chain.Emit("BountyAwarded",
193		"id", itoa(id),
194		"winner", winner.String(),
195		"credited", itoa(credited),
196		"fee", itoa(fee),
197	)
198}
199
200// Cancel closes an open bounty and refunds its escrow to the funder's
201// claimable balance, fee-free. Only the bounty's funder may cancel.
202// Terminal: a cancelled bounty can never change again.
203func Cancel(cur realm, id int64) {
204	caller := cur.Previous().Address()
205	b := mustGetBounty(id)
206	if caller != b.funder {
207		panic("only the bounty funder may cancel")
208	}
209	if b.status != StatusOpen {
210		panic("bounty is not open")
211	}
212
213	if _, _, err := ledger.Deposit(b.funder.String(), b.amount, 0); err != nil {
214		panic(err)
215	}
216	openTotal -= b.amount
217	b.status = StatusCancelled
218
219	chain.Emit("BountyCancelled", "id", itoa(id), "funder", b.funder.String())
220}
221
222// Claim sends amount ugnot of the caller's claimable balance (won
223// bounties and refunds) back to the caller.
224func Claim(cur realm, amount int64) {
225	caller := cur.Previous().Address()
226	if err := ledger.Withdraw(caller.String(), amount); err != nil {
227		panic(err)
228	}
229	send(cur, caller, amount)
230	chain.Emit("Claim", "to", caller.String(), "amount", itoa(amount))
231}
232
233// ClaimAll sends the caller's entire claimable balance back to the
234// caller. Fails if there is nothing to claim.
235func ClaimAll(cur realm) {
236	caller := cur.Previous().Address()
237	amount, err := ledger.WithdrawAll(caller.String())
238	if err != nil {
239		panic(err)
240	}
241	if amount == 0 {
242		panic("nothing to claim")
243	}
244	send(cur, caller, amount)
245	chain.Emit("Claim", "to", caller.String(), "amount", itoa(amount))
246}
247
248// WithdrawFees sends the accrued fee pot to the fee recipient. Only the
249// fee recipient may call it.
250func WithdrawFees(cur realm) {
251	caller := cur.Previous().Address()
252	if caller != feeRecipient {
253		panic("only the fee recipient may withdraw fees")
254	}
255	amount := ledger.WithdrawFees()
256	if amount == 0 {
257		panic("no fees accrued")
258	}
259	send(cur, feeRecipient, amount)
260	chain.Emit("FeesWithdrawn", "to", feeRecipient.String(), "amount", itoa(amount))
261}
262
263// SweepDenom sends the surplus of a single denomination to the fee
264// recipient (the bounded escape hatch validated on the vault realm).
265// For ugnot only the excess over Liabilities() moves; other denoms move
266// wholly. Only the fee recipient may call it.
267func SweepDenom(cur realm, denom string) {
268	caller := cur.Previous().Address()
269	if caller != feeRecipient {
270		panic("only the fee recipient may sweep surplus")
271	}
272	if denom == "" {
273		panic("empty denom")
274	}
275	bk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
276	amount := bk.GetCoin(self, denom)
277	if denom == Denom {
278		amount -= Liabilities()
279	}
280	if amount <= 0 {
281		panic("no surplus to sweep for " + denom)
282	}
283	bk.SendCoins(self, feeRecipient, chain.Coins{chain.NewCoin(denom, amount)})
284	chain.Emit("SurplusSwept", "to", feeRecipient.String(), "coins", itoa(amount)+denom)
285}
286
287// SetFeeBps sets the protocol fee snapshotted into FUTURE bounties at
288// creation. Existing bounties keep the fee they were created under.
289// Admin only; bounded by [0, MaxFeeBps].
290func SetFeeBps(cur realm, bps int64) {
291	assertAdmin(cur.Previous().Address())
292	if bps < 0 || bps > MaxFeeBps {
293		panic("fee bps out of range [0, " + itoa(MaxFeeBps) + "]")
294	}
295	old := feeBps
296	feeBps = bps
297	chain.Emit("FeeBpsChanged", "old", itoa(old), "new", itoa(bps))
298}
299
300// SetFeeRecipient re-points the fee/surplus role, including the pot
301// accrued so far. Admin only; zero address rejected.
302func SetFeeRecipient(cur realm, next address) {
303	assertAdmin(cur.Previous().Address())
304	var zero address
305	if next == zero {
306		panic("empty fee recipient")
307	}
308	old := feeRecipient
309	feeRecipient = next
310	chain.Emit("FeeRecipientChanged", "old", old.String(), "new", next.String())
311}
312
313// TransferAdmin hands the admin role to next. Admin only; zero address
314// rejected. One-step (documented trade-off, as on the vault).
315func TransferAdmin(cur realm, next address) {
316	assertAdmin(cur.Previous().Address())
317	var zero address
318	if next == zero {
319		panic("empty admin address")
320	}
321	admin = next
322	chain.Emit("AdminTransferred", "newAdmin", next.String())
323}
324
325// --- read-only views ---
326
327// BountyInfo returns a bounty's fields by value: funder, title, amount,
328// snapshotted fee bps, status, winner (zero address unless awarded).
329func BountyInfo(id int64) (funder address, title string, amount, feeBps int64, status string, winner address) {
330	b := mustGetBounty(id)
331	return b.funder, b.title, b.amount, b.feeBps, b.status, b.winner
332}
333
334// Admin returns the current admin.
335func Admin() address { return admin }
336
337// FeeRecipient returns who may withdraw fees and sweep surplus.
338func FeeRecipient() address { return feeRecipient }
339
340// FeeBps returns the protocol fee that will be snapshotted into newly
341// created bounties (existing bounties keep their own snapshot).
342func FeeBps() int64 { return feeBps }
343
344// NumBounties returns how many bounties have ever been created.
345func NumBounties() int64 { return nextID }
346
347// OpenTotal returns the escrow held by open bounties (the B term).
348func OpenTotal() int64 { return openTotal }
349
350// BalanceOf returns addr's claimable balance (won bounties + refunds).
351func BalanceOf(addr address) int64 { return ledger.BalanceOf(addr.String()) }
352
353// UsersTotal returns the sum of all claimable balances (the U term).
354func UsersTotal() int64 { return ledger.UsersTotal() }
355
356// FeesAccrued returns the fee pot (the F term).
357func FeesAccrued() int64 { return ledger.FeesAccrued() }
358
359// Liabilities returns everything this realm owes:
360// openTotal + UsersTotal + FeesAccrued.
361func Liabilities() int64 { return openTotal + ledger.Liabilities() }
362
363// Held returns the ugnot actually held at the realm address (the H term).
364func Held() int64 { return banker.NewReadonlyBanker().GetCoin(self, Denom) }
365
366// Surplus returns Held() - Liabilities() (the S term; >= 0 unless a
367// conservation bug exists).
368func Surplus() int64 { return Held() - Liabilities() }
369
370// Address returns this realm's address (the escrow target).
371func Address() address { return self }
372
373// Render shows configuration, totals, the composed conservation check,
374// and the most recent bounties (bounded page, newest first).
375func Render(_ string) string {
376	held := Held()
377	liab := Liabilities()
378	status := "OK"
379	if held < liab {
380		status = "VIOLATED"
381	}
382	out := "# Bounties\n\n"
383	out += "GNOT bounty board; accounting delegated to p/.../feeledger.\n\n"
384	out += "## Configuration\n\n"
385	out += "- fee on award: " + itoa(feeBps) + " bps (cap " + itoa(MaxFeeBps) + " bps)\n"
386	out += "- fee recipient: " + feeRecipient.String() + "\n"
387	out += "- admin: " + admin.String() + "\n\n"
388	out += "## Accounting (H == B + U + F + S)\n\n"
389	out += "- open escrow (B): " + itoa(openTotal) + Denom + "\n"
390	out += "- claimable (U): " + itoa(ledger.UsersTotal()) + Denom + "\n"
391	out += "- fees accrued (F): " + itoa(ledger.FeesAccrued()) + Denom + "\n"
392	out += "- held (H): " + itoa(held) + Denom + "\n"
393	out += "- conservation: " + status + "\n\n"
394	out += "## Latest bounties\n\n"
395	if nextID == 0 {
396		out += "No bounties yet.\n"
397		return out
398	}
399	shown := 0
400	bounties.ReverseIterate("", "", func(_ string, v any) bool {
401		b := v.(*bounty)
402		out += "- #" + itoa(b.id) + " [" + b.status + "] " + b.title +
403			" — " + itoa(b.amount) + Denom + "\n"
404		shown++
405		return shown >= 20
406	})
407	return out
408}
409
410// --- internals ---
411
412func assertAdmin(caller address) {
413	if caller != admin {
414		panic("admin only")
415	}
416}
417
418func mustGetBounty(id int64) *bounty {
419	v := bounties.Get(padID(id))
420	if v == nil {
421		panic("unknown bounty id")
422	}
423	return v.(*bounty)
424}
425
426// assertValidTitle bounds length and restricts the charset so titles
427// cannot inject markdown into Render output.
428func assertValidTitle(title string) {
429	if len(title) == 0 || len(title) > maxTitleLen {
430		panic("title must be 1-" + strconv.Itoa(maxTitleLen) + " characters")
431	}
432	for i := 0; i < len(title); i++ {
433		c := title[i]
434		switch {
435		case c >= 'a' && c <= 'z':
436		case c >= 'A' && c <= 'Z':
437		case c >= '0' && c <= '9':
438		case c == ' ' || c == '_' || c == '-':
439		default:
440			panic("title may only contain letters, digits, space, _ and -")
441		}
442	}
443}
444
445// padID renders an id as a fixed-width key so avl iteration order is
446// numeric order.
447func padID(id int64) string {
448	s := strconv.FormatInt(id, 10)
449	for len(s) < 12 {
450		s = "0" + s
451	}
452	return s
453}
454
455// send moves amount ugnot from the realm to `to`. Callers must have
456// debited the ledger first (checks-effects-interactions).
457func send(cur realm, to address, amount int64) {
458	bk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
459	bk.SendCoins(self, to, chain.Coins{chain.NewCoin(Denom, amount)})
460}
461
462func itoa(n int64) string {
463	return strconv.FormatInt(n, 10)
464}
465
466func checkedAdd(a, b int64) (int64, bool) {
467	sum := a + b
468	if (b > 0 && sum < a) || (b < 0 && sum > a) {
469		return 0, false
470	}
471	return sum, true
472}