grants.gno
20.86 Kb · 614 lines
1// Realm grants is a decentralized grants market: a creator escrows
2// GNOT behind a grant with an application deadline, applicants apply
3// on-chain, the creator selects one applicant, and the winner claims
4// the funding minus a transparent protocol fee.
5//
6// COMPOSITION (per the recorded DISCOVERY / REUSE ANALYSIS): all
7// balance accounting is delegated to feeledger, all coin movement to
8// coinio, and all free-text render output to the ecosystem sanitizer
9// p/nt/markdown/sanitize/v0. This realm owns only the grants state
10// machine: grant records, per-grant applications, open-escrow total,
11// deadlines, and roles.
12//
13// LIFECYCLE (terminal states are frozen; one transition per grant):
14//
15// CreateGrant (EOA + -send) : escrow -> openTotal, status Open,
16// fee bps SNAPSHOTTED at creation
17// Apply (also re-apply/update) : while Open and height < deadline;
18// keyed by the caller's own address
19// SelectWinner (creator only) : Open -> Awarded; winner MUST be an
20// applicant; escrow -> winner's
21// claimable balance minus the
22// snapshotted fee
23// CancelGrant (creator only) : Open -> Cancelled; fee-free refund
24// to the creator's claimable balance
25// ExpireGrant (ANYONE) : Open -> Expired once height >=
26// deadline + ExpiryGraceBlocks;
27// fee-free refund to the creator —
28// the permissionless valve that makes
29// fund-trapping impossible
30// Claim / ClaimAll (anyone) : pays out the caller's own ledger
31// balance (winners and refunds)
32// WithdrawFees (fee recipient) : pays out the fee pot
33//
34// AUTHORIZATION: every identity is derived from the crossing
35// entrypoint's cur.Previous().Address() — no function takes a caller
36// identity as a parameter. Applications and claimable balances are
37// keyed by that runtime-derived address, so altering another user's
38// application or claiming another user's grant is impossible by
39// construction. The creator cannot apply to their own grant, and
40// SelectWinner only accepts addresses that actually applied.
41//
42// DEADLINE SEMANTICS: deadline = ChainHeight() + durationBlocks,
43// fixed at creation (bounded by [MinDurationBlocks, MaxDurationBlocks]).
44// The deadline gates NEW/UPDATED applications only; the creator may
45// select or cancel at any time while the grant is Open. After
46// deadline + ExpiryGraceBlocks anyone may expire the grant.
47//
48// FEE MODEL: fee = floor(amount * bps / 10000), rounding favors the
49// winner; no minimum fee; bps snapshotted into the grant at creation,
50// so SetFeeBps affects future grants only (closes the AWARD-time
51// admin race), and CreateGrant takes the caller's own maxFeeBps
52// ceiling, rejecting creation if the live fee exceeds what the
53// creator signed for (closes the CREATION-time race — audit Y1);
54// hard compile-time cap MaxFeeBps (10%); refunds (cancel/expire) are
55// always fee-free; the pot is withdrawable by the fee-recipient role.
56//
57// MONETARY INVARIANT (conservation): let H be ugnot held at this
58// realm's address, G = openTotal (Σ amount over Open grants), U the
59// ledger's claimable balances, F the fee pot, S >= 0 out-of-band
60// surplus:
61//
62// H == G + U + F + S
63//
64// Every transition moves value between exactly two terms inside one
65// transaction: CreateGrant raises H and G together (coinio.Receive is
66// the receipt-guaranteed shape, validated live on pearl-1);
67// SelectWinner/CancelGrant/ExpireGrant move amount from G into U+F
68// with feeledger guaranteeing credited + fee == amount; claims and fee
69// withdrawal debit the ledger before coinio.Payout moves the identical
70// amount out (checks-effects-interactions); any panic aborts the whole
71// transaction; this realm never issues or removes coins. Surplus is
72// recoverable only via SweepDenom (fee recipient), which reserves
73// Liabilities() = G + U + F.
74//
75// ONLY GNOT: CreateGrant rejects any envelope that is not exactly one
76// positive ugnot coin (coinio.Receive). Foreign denominations sit in
77// surplus.
78package grants
79
80import (
81 "chain"
82 "chain/runtime"
83 "chain/runtime/unsafe"
84 "strconv"
85
86 "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/coinio"
87 "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/feeledger"
88 "gno.land/p/nt/avl/v0"
89 "gno.land/p/nt/markdown/sanitize/v0"
90)
91
92// Denom is the only asset this realm accepts.
93const Denom = "ugnot"
94
95// MaxFeeBps is the hard protocol-fee cap: 1000 bps = 10%.
96const MaxFeeBps = int64(1000)
97
98// Grant status values.
99const (
100 StatusOpen = "open"
101 StatusAwarded = "awarded"
102 StatusCancelled = "cancelled"
103 StatusExpired = "expired"
104)
105
106// Input bounds.
107const (
108 MaxTitleLen = 80
109 MaxDescLen = 2000
110 MaxPitchLen = 1000
111
112 // MinDurationBlocks / MaxDurationBlocks bound the application
113 // window a creator may configure (~5s blocks: 1 block to ~580 days).
114 MinDurationBlocks = int64(1)
115 MaxDurationBlocks = int64(10_000_000)
116
117 // ExpiryGraceBlocks after the deadline, an Open grant becomes
118 // expirable by anyone (~8 minutes at 5s blocks — short because
119 // this is a testnet deployment; a production fork would raise it).
120 ExpiryGraceBlocks = int64(100)
121)
122
123type application struct {
124 pitch string
125 height int64 // block height of the latest (re)submission
126}
127
128type grant struct {
129 id int64
130 creator address
131 title string
132 description string
133 amount int64
134 feeBps int64 // snapshotted at creation, charged at award
135 deadline int64 // block height; applications close here
136 status string
137 winner address // set iff status == StatusAwarded
138 apps *avl.Tree // applicant address string -> *application
139 numApps int64
140}
141
142var (
143 admin address // may set fee, fee recipient, successor admin
144 feeRecipient address // may withdraw fees and sweep surplus
145 feeBps int64 // fee snapshotted into NEW grants
146
147 self address // this realm's address, captured at deploy
148 nextID int64
149 openTotal int64 // == Σ amount over grants with status Open
150
151 grants = avl.NewTree() // padID(id) -> *grant
152 ledger = feeledger.MustNew(MaxFeeBps)
153)
154
155func init() {
156 admin = unsafe.OriginCaller()
157 feeRecipient = admin
158 self = unsafe.CurrentRealm().Address()
159}
160
161// CreateGrant escrows the attached GNOT as a new open grant and
162// returns its id. Only direct EOA calls with -send are accepted. The
163// current protocol fee is snapshotted into the grant — and must not
164// exceed maxFeeBps, the ceiling the caller signed for (rejects a fee
165// raise sequenced ahead of this transaction); pass MaxFeeBps to
166// accept any legal fee. The application window is durationBlocks from
167// now.
168func CreateGrant(cur realm, title, description string, durationBlocks, maxFeeBps int64) int64 {
169 creator, amount := coinio.Receive(0, cur, Denom)
170 if feeBps > maxFeeBps {
171 panic("current fee " + itoa(feeBps) + " bps exceeds the caller's maximum " + itoa(maxFeeBps))
172 }
173 assertValidTitle(title)
174 if len(description) == 0 || len(description) > MaxDescLen {
175 panic("description must be 1-" + strconv.Itoa(MaxDescLen) + " bytes")
176 }
177 if durationBlocks < MinDurationBlocks || durationBlocks > MaxDurationBlocks {
178 panic("durationBlocks out of range [" + itoa(MinDurationBlocks) + ", " + itoa(MaxDurationBlocks) + "]")
179 }
180
181 newOpenTotal, ok := checkedAdd(openTotal, amount)
182 if !ok {
183 panic("open escrow overflow")
184 }
185
186 nextID++
187 g := &grant{
188 id: nextID,
189 creator: creator,
190 title: title,
191 description: description,
192 amount: amount,
193 feeBps: feeBps,
194 deadline: runtime.ChainHeight() + durationBlocks,
195 status: StatusOpen,
196 apps: avl.NewTree(),
197 }
198 grants.Set(padID(g.id), g)
199 openTotal = newOpenTotal
200
201 chain.Emit("GrantCreated",
202 "id", itoa(g.id),
203 "creator", creator.String(),
204 "amount", itoa(amount),
205 "feeBps", itoa(g.feeBps),
206 "deadline", itoa(g.deadline),
207 )
208 return g.id
209}
210
211// Apply submits (or re-submits) the caller's application to an open
212// grant before its deadline. One application per address per grant —
213// re-applying replaces the caller's own pitch only. The creator cannot
214// apply to their own grant.
215func Apply(cur realm, id int64, pitch string) {
216 applicant := cur.Previous().Address()
217 g := mustGetGrant(id)
218 if g.status != StatusOpen {
219 panic("grant is not open")
220 }
221 if runtime.ChainHeight() >= g.deadline {
222 panic("application deadline has passed")
223 }
224 if applicant == g.creator {
225 panic("the creator cannot apply to their own grant")
226 }
227 if len(pitch) == 0 || len(pitch) > MaxPitchLen {
228 panic("pitch must be 1-" + strconv.Itoa(MaxPitchLen) + " bytes")
229 }
230
231 key := applicant.String()
232 if g.apps.Get(key) == nil {
233 g.numApps++
234 }
235 g.apps.Set(key, &application{pitch: pitch, height: runtime.ChainHeight()})
236
237 chain.Emit("Applied", "id", itoa(id), "applicant", key)
238}
239
240// SelectWinner awards an open grant to one of its applicants: the
241// escrow leaves the open pool and is credited to the winner's
242// claimable balance through the ledger, charging the fee snapshotted
243// at creation. Only the grant's creator may select, and only an
244// address that actually applied can win. Terminal.
245func SelectWinner(cur realm, id int64, winner address) {
246 caller := cur.Previous().Address()
247 g := mustGetGrant(id)
248 if caller != g.creator {
249 panic("only the grant creator may select a winner")
250 }
251 if g.status != StatusOpen {
252 panic("grant is not open")
253 }
254 if g.apps.Get(winner.String()) == nil {
255 panic("winner must be an applicant of this grant")
256 }
257
258 // Move the escrow from application state into ledger liabilities in
259 // one transaction, at the snapshotted fee. feeledger validates
260 // before mutating; an error aborts everything and leaves the grant
261 // Open (the creator can retry or cancel).
262 credited, fee, err := ledger.Deposit(winner.String(), g.amount, g.feeBps)
263 if err != nil {
264 panic(err)
265 }
266 openTotal -= g.amount // >= 0: openTotal == Σ open amounts >= g.amount
267 g.status = StatusAwarded
268 g.winner = winner
269
270 chain.Emit("GrantAwarded",
271 "id", itoa(id),
272 "winner", winner.String(),
273 "credited", itoa(credited),
274 "fee", itoa(fee),
275 )
276}
277
278// CancelGrant closes an open grant and refunds its escrow to the
279// creator's claimable balance, fee-free. Only the creator may cancel.
280// Terminal.
281func CancelGrant(cur realm, id int64) {
282 caller := cur.Previous().Address()
283 g := mustGetGrant(id)
284 if caller != g.creator {
285 panic("only the grant creator may cancel")
286 }
287 if g.status != StatusOpen {
288 panic("grant is not open")
289 }
290 refundOpen(g, StatusCancelled)
291 chain.Emit("GrantCancelled", "id", itoa(id), "creator", g.creator.String(), "amount", itoa(g.amount))
292}
293
294// ExpireGrant closes an open grant whose deadline passed more than
295// ExpiryGraceBlocks ago, refunding the creator fee-free. ANYONE may
296// call it — this is the permissionless valve that guarantees escrow
297// can never be trapped by an inactive creator. Terminal.
298func ExpireGrant(cur realm, id int64) {
299 g := mustGetGrant(id)
300 if g.status != StatusOpen {
301 panic("grant is not open")
302 }
303 if runtime.ChainHeight() < g.deadline+ExpiryGraceBlocks {
304 panic("grant is not expirable yet")
305 }
306 refundOpen(g, StatusExpired)
307 chain.Emit("GrantExpired", "id", itoa(g.id), "creator", g.creator.String(), "amount", itoa(g.amount))
308}
309
310// refundOpen moves an Open grant's escrow into the creator's claimable
311// balance fee-free and finalizes the status. Callers have already
312// verified authorization and status.
313func refundOpen(g *grant, terminal string) {
314 if _, _, err := ledger.Deposit(g.creator.String(), g.amount, 0); err != nil {
315 panic(err)
316 }
317 openTotal -= g.amount
318 g.status = terminal
319}
320
321// Claim sends amount ugnot of the caller's claimable balance (won
322// grants and refunds) back to the caller.
323func Claim(cur realm, amount int64) {
324 caller := cur.Previous().Address()
325 if err := ledger.Withdraw(caller.String(), amount); err != nil {
326 panic(err)
327 }
328 coinio.Payout(0, cur, caller, Denom, amount)
329 chain.Emit("Claim", "to", caller.String(), "amount", itoa(amount))
330}
331
332// ClaimAll sends the caller's entire claimable balance back to the
333// caller. Fails if there is nothing to claim.
334func ClaimAll(cur realm) {
335 caller := cur.Previous().Address()
336 amount, err := ledger.WithdrawAll(caller.String())
337 if err != nil {
338 panic(err)
339 }
340 if amount == 0 {
341 panic("nothing to claim")
342 }
343 coinio.Payout(0, cur, caller, Denom, amount)
344 chain.Emit("Claim", "to", caller.String(), "amount", itoa(amount))
345}
346
347// WithdrawFees sends the accrued fee pot to the fee recipient. Only
348// the fee recipient may call it.
349func WithdrawFees(cur realm) {
350 caller := cur.Previous().Address()
351 if caller != feeRecipient {
352 panic("only the fee recipient may withdraw fees")
353 }
354 if ledger.FeesAccrued() == 0 {
355 panic("no fees accrued")
356 }
357 amount := ledger.WithdrawFees()
358 coinio.Payout(0, cur, feeRecipient, Denom, amount)
359 chain.Emit("FeesWithdrawn", "to", feeRecipient.String(), "amount", itoa(amount))
360}
361
362// SweepDenom sends the surplus of a single denomination to the fee
363// recipient. For ugnot only the excess over Liabilities() moves; other
364// denoms move wholly. Only the fee recipient may call it.
365func SweepDenom(cur realm, denom string) {
366 caller := cur.Previous().Address()
367 if caller != feeRecipient {
368 panic("only the fee recipient may sweep surplus")
369 }
370 reserve := int64(0)
371 if denom == Denom {
372 reserve = Liabilities()
373 }
374 swept := coinio.Sweep(0, cur, feeRecipient, denom, reserve)
375 chain.Emit("SurplusSwept", "to", feeRecipient.String(), "coins", itoa(swept)+denom)
376}
377
378// SetFeeBps sets the protocol fee snapshotted into FUTURE grants at
379// creation. Existing grants keep the fee they were created under.
380// Admin only; bounded by [0, MaxFeeBps].
381func SetFeeBps(cur realm, bps int64) {
382 assertAdmin(cur.Previous().Address())
383 if bps < 0 || bps > MaxFeeBps {
384 panic("fee bps out of range [0, " + itoa(MaxFeeBps) + "]")
385 }
386 old := feeBps
387 feeBps = bps
388 chain.Emit("FeeBpsChanged", "old", itoa(old), "new", itoa(bps))
389}
390
391// SetFeeRecipient re-points the fee/surplus role, including the pot
392// accrued so far. Admin only; zero address rejected.
393func SetFeeRecipient(cur realm, next address) {
394 assertAdmin(cur.Previous().Address())
395 var zero address
396 if next == zero {
397 panic("empty fee recipient")
398 }
399 old := feeRecipient
400 feeRecipient = next
401 chain.Emit("FeeRecipientChanged", "old", old.String(), "new", next.String())
402}
403
404// TransferAdmin hands the admin role to next. Admin only; zero address
405// rejected. One-step (documented trade-off).
406func TransferAdmin(cur realm, next address) {
407 assertAdmin(cur.Previous().Address())
408 var zero address
409 if next == zero {
410 panic("empty admin address")
411 }
412 admin = next
413 chain.Emit("AdminTransferred", "newAdmin", next.String())
414}
415
416// --- read-only views ---
417
418// GrantInfo returns a grant's fields by value: creator, title, amount,
419// snapshotted fee bps, application deadline (block height), status,
420// winner (zero unless awarded), and number of applicants.
421func GrantInfo(id int64) (creator address, title string, amount, feeBps, deadline int64, status string, winner address, numApplicants int64) {
422 g := mustGetGrant(id)
423 return g.creator, g.title, g.amount, g.feeBps, g.deadline, g.status, g.winner, g.numApps
424}
425
426// Description returns a grant's raw description text.
427func Description(id int64) string { return mustGetGrant(id).description }
428
429// ApplicationOf returns addr's pitch and submission height for a
430// grant, with ok reporting whether an application exists.
431func ApplicationOf(id int64, addr address) (pitch string, height int64, ok bool) {
432 g := mustGetGrant(id)
433 v := g.apps.Get(addr.String())
434 if v == nil {
435 return "", 0, false
436 }
437 a := v.(*application)
438 return a.pitch, a.height, true
439}
440
441// Admin returns the current admin.
442func Admin() address { return admin }
443
444// FeeRecipient returns who may withdraw fees and sweep surplus.
445func FeeRecipient() address { return feeRecipient }
446
447// FeeBps returns the fee that will be snapshotted into newly created
448// grants (existing grants keep their own snapshot).
449func FeeBps() int64 { return feeBps }
450
451// FeeOn previews the fee and net payout for a grant of amount at the
452// CURRENT FeeBps.
453func FeeOn(amount int64) (fee, credited int64) {
454 f, err := feeledger.FeeFor(amount, feeBps)
455 if err != nil {
456 panic(err)
457 }
458 return f, amount - f
459}
460
461// NumGrants returns how many grants have ever been created.
462func NumGrants() int64 { return nextID }
463
464// OpenTotal returns the escrow held by open grants (the G term).
465func OpenTotal() int64 { return openTotal }
466
467// BalanceOf returns addr's claimable balance (won grants + refunds).
468func BalanceOf(addr address) int64 { return ledger.BalanceOf(addr.String()) }
469
470// UsersTotal returns the sum of all claimable balances (the U term).
471func UsersTotal() int64 { return ledger.UsersTotal() }
472
473// FeesAccrued returns the fee pot (the F term).
474func FeesAccrued() int64 { return ledger.FeesAccrued() }
475
476// Liabilities returns everything this realm owes:
477// openTotal + UsersTotal + FeesAccrued.
478func Liabilities() int64 { return openTotal + ledger.Liabilities() }
479
480// Held returns the ugnot actually held at the realm address (H).
481func Held() int64 { return coinio.HeldAt(self, Denom) }
482
483// Surplus returns Held() - Liabilities() (the S term).
484func Surplus() int64 { return Held() - Liabilities() }
485
486// Address returns this realm's address (the escrow target).
487func Address() address { return self }
488
489// Height returns the current chain height (deadline arithmetic aid).
490func Height() int64 { return runtime.ChainHeight() }
491
492// Render shows the market at "" and a grant detail at "<id>". Free
493// text (titles are charset-restricted; descriptions are not) passes
494// through the ecosystem sanitizer before hitting markdown.
495func Render(path string) string {
496 if path != "" {
497 return renderGrant(path)
498 }
499 held := Held()
500 liab := Liabilities()
501 status := "OK"
502 if held < liab {
503 status = "VIOLATED"
504 }
505 out := "# Grants market\n\n"
506 out += "Escrowed GNOT grants with on-chain applications; accounting via feeledger, coin I/O via coinio.\n\n"
507 out += "## Configuration\n\n"
508 out += "- fee for new grants: " + itoa(feeBps) + " bps (cap " + itoa(MaxFeeBps) +
509 " bps; each grant keeps the fee snapshotted at its creation)\n"
510 out += "- fee recipient: " + feeRecipient.String() + "\n"
511 out += "- admin: " + admin.String() + "\n\n"
512 out += "## Accounting (H == G + U + F + S)\n\n"
513 out += "- open escrow (G): " + itoa(openTotal) + Denom + "\n"
514 out += "- claimable (U): " + itoa(ledger.UsersTotal()) + Denom + "\n"
515 out += "- fees accrued (F): " + itoa(ledger.FeesAccrued()) + Denom + "\n"
516 out += "- held (H): " + itoa(held) + Denom + "\n"
517 out += "- conservation: " + status + "\n\n"
518 out += "## Latest grants\n\n"
519 if nextID == 0 {
520 out += "No grants yet.\n"
521 return out
522 }
523 shown := 0
524 grants.ReverseIterate("", "", func(_ string, v any) bool {
525 g := v.(*grant)
526 out += "- [#" + itoa(g.id) + "](" + "/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/grants:" + itoa(g.id) + ") [" +
527 g.status + "] " + g.title + " — " + itoa(g.amount) + Denom +
528 " (" + itoa(g.numApps) + " applicants)\n"
529 shown++
530 return shown >= 20
531 })
532 return out
533}
534
535func renderGrant(path string) string {
536 id, err := strconv.ParseInt(path, 10, 64)
537 if err != nil {
538 return "> [!WARNING]\n> invalid grant id\n"
539 }
540 v := grants.Get(padID(id))
541 if v == nil {
542 return "> [!WARNING]\n> unknown grant id\n"
543 }
544 g := v.(*grant)
545 out := "# Grant #" + itoa(g.id) + ": " + g.title + "\n\n"
546 out += "- status: " + g.status + "\n"
547 out += "- creator: " + g.creator.String() + "\n"
548 out += "- amount: " + itoa(g.amount) + Denom + "\n"
549 out += "- fee (snapshot): " + itoa(g.feeBps) + " bps\n"
550 out += "- application deadline: block " + itoa(g.deadline) + " (now " + itoa(runtime.ChainHeight()) + ")\n"
551 out += "- applicants: " + itoa(g.numApps) + "\n"
552 if g.status == StatusAwarded {
553 out += "- winner: " + g.winner.String() + "\n"
554 }
555 out += "\n## Description\n\n" + sanitize.InlineText(g.description) + "\n"
556 return out
557}
558
559// --- internals ---
560
561func assertAdmin(caller address) {
562 if caller != admin {
563 panic("admin only")
564 }
565}
566
567func mustGetGrant(id int64) *grant {
568 v := grants.Get(padID(id))
569 if v == nil {
570 panic("unknown grant id")
571 }
572 return v.(*grant)
573}
574
575// assertValidTitle bounds length and restricts the charset so titles
576// are list-safe in Render without escaping.
577func assertValidTitle(title string) {
578 if len(title) == 0 || len(title) > MaxTitleLen {
579 panic("title must be 1-" + strconv.Itoa(MaxTitleLen) + " characters")
580 }
581 for i := 0; i < len(title); i++ {
582 c := title[i]
583 switch {
584 case c >= 'a' && c <= 'z':
585 case c >= 'A' && c <= 'Z':
586 case c >= '0' && c <= '9':
587 case c == ' ' || c == '_' || c == '-':
588 default:
589 panic("title may only contain letters, digits, space, _ and -")
590 }
591 }
592}
593
594// padID renders an id as a fixed-width key so avl iteration order is
595// numeric order.
596func padID(id int64) string {
597 s := strconv.FormatInt(id, 10)
598 for len(s) < 12 {
599 s = "0" + s
600 }
601 return s
602}
603
604func itoa(n int64) string {
605 return strconv.FormatInt(n, 10)
606}
607
608func checkedAdd(a, b int64) (int64, bool) {
609 sum := a + b
610 if (b > 0 && sum < a) || (b < 0 && sum > a) {
611 return 0, false
612 }
613 return sum, true
614}