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

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}