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

permbook_demo.gno

19.44 Kb · 566 lines
  1// Package permbook_demo is the reference consumer for the permbook
  2// permission primitive. It exists to make permbook's central claim
  3// falsifiable on a live chain.
  4//
  5// That claim is: a permission revoked in transaction N is refused in
  6// transaction N+1. A pure package cannot demonstrate this — a vm/qeval is a
  7// single ephemeral evaluation and nothing it writes survives it. Only a
  8// realm, called across several transactions, can show a grant taking effect
  9// and then a revoke taking it away. So this realm is not a decoration on the
 10// primitive; it is the experiment.
 11//
 12// # What it does
 13//
 14// Two permission-gated actions, deliberately trivial so that the only
 15// interesting thing about them is the gate:
 16//
 17//   - bump   — increments a counter and records who did it
 18//   - notice — replaces a short public notice
 19//
 20// Neither action is reachable without the corresponding permission. The
 21// deployer is the book's admin and is the only account that may grant or
 22// revoke. Holding "bump" confers no authority over the book itself: a holder
 23// cannot grant, cannot revoke, and cannot nominate. That separation is the
 24// property being demonstrated.
 25//
 26// # How it wires permbook correctly
 27//
 28// This realm is also the worked example of permbook's consumer contract:
 29//
 30//   - every permbook mutator is called from a CROSSING entrypoint, passing
 31//     that entrypoint's own cur, so the principal permbook resolves is this
 32//     realm's immediate caller (contract 1);
 33//   - the *Book is an unexported package-level var and is never returned
 34//     across a realm boundary (contract 2);
 35//   - Has is asked about an address this realm derived itself from
 36//     cur.Previous().Address(), never about a parameter (contract 3);
 37//   - the limits are chosen once, at init, and documented below (contract 4).
 38//
 39// This realm holds no coins and imports no banker. Every crossing entrypoint
 40// rejects attached coins; the read views are non-crossing, which MsgCall will
 41// not dispatch to, so no transaction can attach coins to one.
 42package permbook_demo
 43
 44import (
 45	"chain"
 46	"chain/runtime"
 47	"chain/runtime/unsafe"
 48	"strconv"
 49
 50	"gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/permbook"
 51	"gno.land/p/nt/markdown/sanitize/v0"
 52)
 53
 54// The permission names this realm gates on. They are constants rather than
 55// caller-supplied strings for the gated actions, so a typo cannot silently
 56// create an unenforced gate.
 57const (
 58	PermBump   = "bump"
 59	PermNotice = "notice"
 60)
 61
 62const (
 63	// The book's limits. Deliberately far below permbook's ceilings: this
 64	// realm needs two permissions and a handful of holders, and a bound
 65	// should describe what the application actually does rather than the
 66	// most the library would tolerate. A few spare slots are left so the
 67	// permission limit can be exercised on-chain without wedging the demo.
 68	MaxPermissions = 8
 69	MaxHolders     = 32
 70	MaxNameLen     = 24
 71
 72	// MaxNoticeLen bounds the public notice. A BYTE length, not a rune
 73	// count, because its job is to bound storage.
 74	MaxNoticeLen = 200
 75
 76	// MaxListed caps how many rows any single query or view returns, so no
 77	// read is unbounded regardless of how full the book is.
 78	MaxListed = 50
 79)
 80
 81// book is the permission set. It is unexported and never returned: handing a
 82// *permbook.Book across a realm boundary would hand out every mutator on it,
 83// and borrow rule #2 commits those writes under THIS realm's authority
 84// (permbook consumer contract 2).
 85var book *permbook.Book
 86
 87// The state the gated actions move. This is the observable evidence that a
 88// gate was open at one height and closed at another.
 89var (
 90	bumps          int64
 91	lastBumper     address
 92	lastBumpHeight int64
 93
 94	notice           string
 95	noticeAuthor     address
 96	noticeSetAtBlock int64
 97)
 98
 99// demoLimits are the limits every Book this realm constructs is given.
100func demoLimits() permbook.Limits {
101	return permbook.Limits{
102		MaxPermissions:          MaxPermissions,
103		MaxHoldersPerPermission: MaxHolders,
104		MaxNameLen:              MaxNameLen,
105	}
106}
107
108func init() {
109	// The package deployer becomes the book's admin: on-chain, AddPackage
110	// runs init with the message creator as origin caller, and a
111	// MsgAddPackage's creator is never zero.
112	//
113	// Under `gno test` there is no MsgAddPackage, so the origin context is
114	// empty and the address is invalid; the test file's own init constructs
115	// the book instead. On-chain that branch is unreachable, and if it ever
116	// were reached the realm would deploy INERT — book stays nil and every
117	// entrypoint aborts rather than running ungated. For a realm that holds
118	// no value and gates two trivial actions, inert is the safe direction.
119	// TestInertRealmFailsClosed exercises exactly that state, so the claim
120	// is test-backed rather than argued.
121	//
122	// An audit of this file recommended panicking here instead, to make the
123	// invariant structural. That was tried and rejected on evidence: the
124	// realm's init runs before the test file's, so a panic makes the whole
125	// package unloadable under `gno test` —
126	//
127	//	permbook_demo: no deploy-time admin (code=gnoUnknownError)
128	//	FAIL: 0 build errors, 1 test errors
129	//
130	// which trades all 16 tests, including the central authorization claim,
131	// for a guard on a branch the audit itself could not reach on-chain.
132	// The early return plus a fail-closed test is the better trade.
133	//
134	// The deploy-time obligation this leaves is real and belongs in the
135	// runbook, not in the code: check Ready() immediately after deploying
136	// and treat false as a FAILED deploy, because it is unrecoverable —
137	// nothing assigns book after init, so a fresh path is the only remedy.
138	deployer := unsafe.OriginCaller()
139	if !deployer.IsValid() {
140		return
141	}
142	b, err := permbook.New(deployer, demoLimits())
143	if err != nil {
144		panic(err)
145	}
146	book = b
147}
148
149// Ready reports whether init captured a deploy-time admin and constructed
150// the book. False means the realm is inert; see the note in init.
151func Ready() bool { return book != nil }
152
153// rejectStraySend aborts if an EOA attached coins to an entrypoint that has
154// no use for them. This realm never holds value; without this guard a
155// mistyped send would be silently absorbed by the realm bank.
156func rejectStraySend(cur realm) {
157	if cur.Previous().IsUserCall() && len(unsafe.OriginSend()) > 0 {
158		panic("this entrypoint does not accept coins")
159	}
160}
161
162// --- the gated actions ----------------------------------------------------
163//
164// Each one asks permbook about an address IT derived from its own crossing
165// frame. The address is never a parameter (consumer contract 3).
166
167// Bump increments the counter. Requires the "bump" permission.
168//
169// This is the experiment: call it with the permission and it succeeds; have
170// the admin revoke, call it again in a later transaction, and it aborts.
171func Bump(cur realm) string {
172	rejectStraySend(cur)
173
174	caller := cur.Previous().Address()
175	if !book.Has(PermBump, caller) {
176		panic("permission denied: " + PermBump)
177	}
178
179	now := runtime.ChainHeight()
180	bumps++
181	lastBumper = caller
182	lastBumpHeight = now
183
184	chain.Emit("Bumped",
185		"by", caller.String(),
186		"count", itoa(bumps),
187		"height", itoa(now),
188	)
189	return "bump " + itoa(bumps) + " at height " + itoa(now)
190}
191
192// SetNotice replaces the public notice. Requires the "notice" permission.
193//
194// It exists so the demo shows two INDEPENDENT permissions on one book:
195// holding "bump" does not let an address set the notice, and revoking one
196// leaves the other intact.
197func SetNotice(cur realm, text string) string {
198	rejectStraySend(cur)
199
200	caller := cur.Previous().Address()
201	if !book.Has(PermNotice, caller) {
202		panic("permission denied: " + PermNotice)
203	}
204	if text == "" {
205		panic("notice must not be empty")
206	}
207	if len(text) > MaxNoticeLen {
208		panic("notice exceeds " + itoa(MaxNoticeLen) + " bytes")
209	}
210
211	now := runtime.ChainHeight()
212	notice = text
213	noticeAuthor = caller
214	noticeSetAtBlock = now
215
216	chain.Emit("NoticeSet", "by", caller.String(), "height", itoa(now))
217	return "notice set at height " + itoa(now)
218}
219
220// --- administration -------------------------------------------------------
221//
222// Every one of these is a crossing entrypoint that threads its OWN cur into
223// permbook, so permbook resolves this realm's immediate caller as the
224// principal (consumer contract 1). None of them takes a caller address as an
225// argument; there is nothing here to forge.
226
227// Grant gives addr a permission. Admin only.
228func Grant(cur realm, perm string, addr address) string {
229	rejectStraySend(cur)
230	if err := book.Grant(0, cur, perm, addr); err != nil {
231		panic(err)
232	}
233	chain.Emit("Granted", "permission", perm, "to", addr.String())
234	return "granted " + perm + " to " + addr.String()
235}
236
237// Revoke removes a permission from addr. Admin only.
238//
239// The revoke is committed by this transaction. Any later transaction that
240// reaches a gate on that permission is refused — that is the claim.
241func Revoke(cur realm, perm string, addr address) string {
242	rejectStraySend(cur)
243	if err := book.Revoke(0, cur, perm, addr); err != nil {
244		panic(err)
245	}
246	chain.Emit("Revoked", "permission", perm, "from", addr.String())
247	return "revoked " + perm + " from " + addr.String()
248}
249
250// RevokeAll removes every permission addr holds and reports how many.
251// Admin only.
252func RevokeAll(cur realm, addr address) string {
253	rejectStraySend(cur)
254	n, err := book.RevokeAll(0, cur, addr)
255	if err != nil {
256		panic(err)
257	}
258	chain.Emit("RevokedAll", "from", addr.String(), "count", itoa(int64(n)))
259	return "revoked " + itoa(int64(n)) + " permission(s) from " + addr.String()
260}
261
262// DropPermission removes a permission and every grant of it. Admin only.
263func DropPermission(cur realm, perm string) string {
264	rejectStraySend(cur)
265	if err := book.DropPermission(0, cur, perm); err != nil {
266		panic(err)
267	}
268	chain.Emit("PermissionDropped", "permission", perm)
269	return "dropped " + perm
270}
271
272// NominateAdmin records a nominee for the admin role. Admin only. The
273// handoff does not take effect until the nominee calls AcceptAdmin.
274func NominateAdmin(cur realm, nominee address) string {
275	rejectStraySend(cur)
276	if err := book.NominateAdmin(0, cur, nominee); err != nil {
277		panic(err)
278	}
279	chain.Emit("AdminNominated", "nominee", nominee.String())
280	return "nominated " + nominee.String()
281}
282
283// CancelNomination withdraws a pending nomination. Admin only.
284func CancelNomination(cur realm) string {
285	rejectStraySend(cur)
286	if err := book.CancelNomination(0, cur); err != nil {
287		panic(err)
288	}
289	chain.Emit("AdminNominationCancelled")
290	return "nomination cancelled"
291}
292
293// AcceptAdmin completes a pending handoff. Only the nominee may call it.
294func AcceptAdmin(cur realm) string {
295	rejectStraySend(cur)
296	if err := book.AcceptAdmin(0, cur); err != nil {
297		panic(err)
298	}
299	who := cur.Previous().Address()
300	chain.Emit("AdminAccepted", "admin", who.String())
301	return "admin is now " + who.String()
302}
303
304// --- read-only views ------------------------------------------------------
305//
306// None of these authorizes anything, and none of them authenticates its
307// caller. They answer questions about an address (permbook consumer
308// contract 3).
309
310// Height returns the chain height this realm is reading as "now".
311func Height() int64 { return runtime.ChainHeight() }
312
313// Admin returns the book's current admin.
314func Admin() string { return book.Admin().String() }
315
316// PendingAdmin returns the nominated-but-not-yet-accepted admin, or "".
317func PendingAdmin() string { return book.PendingAdmin().String() }
318
319// Has reports whether addr holds perm. This is the primitive's core query,
320// exposed verbatim so it can be checked from off-chain.
321func Has(perm string, addr address) bool { return book.Has(perm, addr) }
322
323// CanBump reports whether addr would be allowed to call Bump right now.
324func CanBump(addr address) bool { return book.Has(PermBump, addr) }
325
326// PermissionsOf returns the permissions addr holds, comma-separated in
327// lexicographic order, or "" for none. Names are restricted by permbook to
328// lowercase alphanumerics and underscore, so a comma can never appear in
329// one and this encoding is unambiguous.
330func PermissionsOf(addr address) string {
331	return join(book.Permissions(addr))
332}
333
334// PermissionCount returns how many distinct permissions currently exist.
335func PermissionCount() int { return book.PermissionCount() }
336
337// Permissions returns up to MaxListed permission names, comma-separated in
338// lexicographic order.
339func Permissions() string {
340	return join(book.PermissionNames(0, MaxListed))
341}
342
343// HolderCount returns how many addresses hold perm, or 0 if it does not
344// exist.
345func HolderCount(perm string) int { return book.HolderCount(perm) }
346
347// Holders returns up to MaxListed holders of perm, comma-separated in
348// sorted order.
349func Holders(perm string) string {
350	hs := book.Holders(perm, 0, MaxListed)
351	out := ""
352	for i, h := range hs {
353		if i > 0 {
354			out += ","
355		}
356		out += h.String()
357	}
358	return out
359}
360
361// Bumps returns how many times Bump has succeeded.
362func Bumps() int64 { return bumps }
363
364// LastBump describes the most recent successful Bump, or "none".
365func LastBump() string {
366	if bumps == 0 {
367		return "none"
368	}
369	return lastBumper.String() + " at height " + itoa(lastBumpHeight)
370}
371
372// Notice returns the current public notice, or "".
373func Notice() string { return notice }
374
375// Limits reports the book's fixed limits.
376func Limits() string {
377	l := book.Limits()
378	return "permissions=" + itoa(int64(l.MaxPermissions)) +
379		" holders=" + itoa(int64(l.MaxHoldersPerPermission)) +
380		" namelen=" + itoa(int64(l.MaxNameLen))
381}
382
383// --- rendering ------------------------------------------------------------
384
385// realmPath is this realm's gnoweb prefix, used to build view links.
386const realmPath = "/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/permbook_demo"
387
388// Render serves three views, selected by path.
389//
390//	(empty)  summary, the permission table, and the gated state
391//	holders  every permission with its holders, up to MaxListed each
392//	about    what this realm is and why it exists
393//
394// An inert realm (see init) reports that instead of rendering. Every view
395// reads the book, so without this the gnoweb page would abort on a nil
396// dereference — which is exactly the moment an operator needs it to say what
397// is wrong. The typed read functions still abort when inert; Ready is the
398// probe that answers the question without panicking.
399func Render(path string) string {
400	if !Ready() {
401		return "# Permission demo\n\n" +
402			"This realm is **inert**: init captured no deploy-time admin, so " +
403			"no permission book exists. Every entrypoint aborts and nothing " +
404			"runs ungated. The state is unrecoverable — redeploy to a fresh " +
405			"path.\n"
406	}
407
408	switch normalizePath(path) {
409	case "holders":
410		return renderHeader() + renderHolders()
411	case "about":
412		return renderHeader() + renderAbout()
413	default:
414		return renderHeader() + renderPermissions() + renderState()
415	}
416}
417
418func renderHeader() string {
419	s := "# Permission demo\n\n"
420	s += "Reference consumer for [permbook](/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/permbook).\n\n"
421	s += "- height: " + itoa(runtime.ChainHeight()) + "\n"
422	s += "- admin: " + sanitize.InlineText(book.Admin().String()) + "\n"
423	if p := book.PendingAdmin(); p != address("") {
424		s += "- pending admin: " + sanitize.InlineText(p.String()) + "\n"
425	}
426	s += "- permissions: " + itoa(int64(book.PermissionCount())) +
427		" / " + itoa(int64(MaxPermissions)) + "\n\n"
428	s += "[summary](" + realmPath + ") · "
429	s += "[holders](" + realmPath + ":holders) · "
430	s += "[about](" + realmPath + ":about)\n\n"
431	return s
432}
433
434func renderPermissions() string {
435	names := book.PermissionNames(0, MaxListed)
436
437	s := "## Permissions\n\n"
438	if len(names) == 0 {
439		return s + "_none granted yet._\n\n"
440	}
441	s += "| permission | holders | gates |\n|---|---|---|\n"
442	for _, n := range names {
443		s += "| " + sanitize.InlineText(n) +
444			" | " + itoa(int64(book.HolderCount(n))) +
445			" | " + gateFor(n) + " |\n"
446	}
447	if book.PermissionCount() > len(names) {
448		s += "\nShowing the first " + itoa(int64(len(names))) + " of " +
449			itoa(int64(book.PermissionCount())) + ".\n"
450	}
451	return s + "\n"
452}
453
454// gateFor names the entrypoint a permission opens, or says it opens none.
455// A permission with no gate is not a bug: the admin may grant a name this
456// realm does not read, and saying so plainly is better than implying every
457// permission is enforced somewhere.
458func gateFor(perm string) string {
459	switch perm {
460	case PermBump:
461		return "`Bump`"
462	case PermNotice:
463		return "`SetNotice`"
464	default:
465		return "_no entrypoint reads this_"
466	}
467}
468
469func renderHolders() string {
470	names := book.PermissionNames(0, MaxListed)
471
472	s := "## Holders\n\n"
473	if len(names) == 0 {
474		return s + "_none granted yet._\n\n"
475	}
476	for _, n := range names {
477		total := book.HolderCount(n)
478		s += "**" + sanitize.InlineText(n) + "** — " + itoa(int64(total)) + " holder(s)\n\n"
479		for _, h := range book.Holders(n, 0, MaxListed) {
480			s += "- " + sanitize.InlineText(h.String()) + "\n"
481		}
482		if total > MaxListed {
483			s += "\n_Showing the first " + itoa(int64(MaxListed)) + " of " +
484				itoa(int64(total)) + "._\n"
485		}
486		s += "\n"
487	}
488	return s
489}
490
491func renderState() string {
492	s := "## Gated state\n\n"
493	s += "- bumps: " + itoa(bumps) + "\n"
494	s += "- last bump: "
495	if bumps == 0 {
496		s += "_none_\n"
497	} else {
498		s += sanitize.InlineText(lastBumper.String()) +
499			" at height " + itoa(lastBumpHeight) + "\n"
500	}
501	s += "\n### Notice\n\n"
502	if notice == "" {
503		return s + "_none set._\n\n"
504	}
505	// Blockquote, not InlineText: the slot IS a blockquote, so the helper
506	// whose contract covers the slot is the one to use. Blockquote emits
507	// its own envelope, so no manual "> " prefix here.
508	s += sanitize.Blockquote(notice)
509	s += "set by " + sanitize.InlineText(noticeAuthor.String()) +
510		" at height " + itoa(noticeSetAtBlock) + "\n\n"
511	return s
512}
513
514func renderAbout() string {
515	return "## About\n\n" +
516		"Two permission-gated entrypoints, deliberately trivial so that the " +
517		"only interesting thing about them is the gate:\n\n" +
518		"- `Bump` requires `" + PermBump + "`\n" +
519		"- `SetNotice` requires `" + PermNotice + "`\n\n" +
520		"The deployer is the book's admin and is the only account that may " +
521		"grant or revoke. **Holding a permission confers no authority over " +
522		"the book**: a holder cannot grant, cannot revoke, and cannot " +
523		"nominate an admin.\n\n" +
524		"The claim this realm makes falsifiable is that a permission revoked " +
525		"in one transaction is refused in the next. Grant `" + PermBump +
526		"`, call `Bump` — it succeeds. Revoke, call `Bump` again — it " +
527		"aborts. A pure package cannot show that; only a realm called across " +
528		"several transactions can.\n\n" +
529		"This realm holds no coins and imports no banker. Every crossing " +
530		"entrypoint rejects attached coins, and the read views are " +
531		"non-crossing so no transaction can attach coins to one.\n\n" +
532		"- max permissions: " + itoa(int64(MaxPermissions)) + "\n" +
533		"- max holders per permission: " + itoa(int64(MaxHolders)) + "\n" +
534		"- max permission name length: " + itoa(int64(MaxNameLen)) + "\n" +
535		"- max notice length: " + itoa(int64(MaxNoticeLen)) + " bytes\n"
536}
537
538// normalizePath reduces a gnoweb render path to a bare view name, so "",
539// "/", "holders" and "/holders/" all select the same view.
540func normalizePath(path string) string {
541	for len(path) > 0 && path[0] == '/' {
542		path = path[1:]
543	}
544	for len(path) > 0 && path[len(path)-1] == '/' {
545		path = path[:len(path)-1]
546	}
547	return path
548}
549
550// --- helpers --------------------------------------------------------------
551
552// join comma-separates names. Safe because permbook restricts permission
553// names to lowercase alphanumerics and underscore, so no name can contain
554// the delimiter.
555func join(names []string) string {
556	out := ""
557	for i, n := range names {
558		if i > 0 {
559			out += ","
560		}
561		out += n
562	}
563	return out
564}
565
566func itoa(n int64) string { return strconv.FormatInt(n, 10) }