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) }