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}