delegate.gno
6.77 Kb · 156 lines
1package params
2
3// Delegation: letting a realm other than GovDAO manage one specific parameter.
4//
5// This realm holds a capability nothing else can: the `sys/params` stdlib
6// refuses every caller except `gno.land/r/sys/params`, checked in the VM by
7// package path, and the same gate covers the getters. So every parameter write
8// on the chain physically originates here, and "delegating a parameter" can only
9// mean adding a path in this file that authorizes someone other than a GovDAO
10// vote.
11//
12// Two shapes are deliberately NOT used.
13//
14// Not a registry of key -> realm. This realm can never be redeployed
15// (AddPackage refuses an occupied path), and the stdlib gate names this exact
16// path, so making a NEW parameter delegatable already requires editing this
17// file, which means a chain relaunch. A registry's runtime generality would
18// therefore never be exercised: the only freedom that matters is "which realm,
19// or none" for keys already blessed in source. A named slot says exactly that
20// and nothing more. It also removes a whole class of mistake — there is no
21// key string to mis-compose, no container to accidentally expose, and no way for
22// a proposal to name a key the author never considered. A shape-based allowlist
23// like "<module>:p:<name>" would have admitted bank:p:restricted_denoms and
24// auth:p:unrestricted_addrs, which is not a delegation anyone asked for.
25//
26// Not a capability object. Returning something the delegate holds would make
27// authority survive revocation, because the check would have happened at
28// construction. The authority is re-checked on every crossing call instead, so
29// clearing the slot takes effect immediately.
30
31import (
32 "chain"
33
34 "gno.land/r/gov/dao"
35)
36
37// Event names. PascalCase with a named const is the house style across r/sys
38// and r/gov; the bare lowercase "set" elsewhere in this realm predates it.
39const (
40 DelegateSetEvent = "ParamDelegateSet"
41 DelegateClearedEvent = "ParamDelegateCleared"
42 DelegateWriteEvent = "ParamDelegateWrite"
43)
44
45// assertDelegate authorizes a caller as the holder of a delegated capability.
46//
47// want is the authorized package path, or "" when nothing is delegated.
48//
49// The empty check comes FIRST and is load-bearing, not defensive. A direct call
50// from a user account has an empty previous package path — that is exactly what
51// IsUserCall tests — so comparing against an unset slot would compare "" to ""
52// and admit every user on the chain. r/gov/dao's own allowlist has the
53// mirror-image bug (it returns true for everyone when the list is empty), kept
54// there deliberately for genesis bootstrap. Nothing here needs that, so empty
55// denies.
56//
57// Matching is exact, never a prefix. A sub-realm identity minted by
58// cur.Sub(subpath) presents the synthesized path "host#subpath", so a delegate
59// may legitimately be a single DAO hosted by a multi-tenant realm — for
60// instance "gno.land/r/nt/commondao/v0#dao/42". An anchored-prefix match on the
61// host would hand the capability to every DAO that realm hosts, and a bare
62// prefix match would additionally match sibling packages. "#" cannot occur in a
63// real package path, so exact matching on the full string is unambiguous.
64//
65// Takes rlm in the non-crossing dispatch position, matching assertValsetCaller
66// so the two gates stay one shape.
67// subject names what is being written, so the refusal stays specific to the
68// capability rather than generic to the mechanism. params_valset_auth.txtar
69// pins valset's exact wording as a regression test for PR #5485.
70func assertDelegate(_ int, rlm realm, want, subject string) {
71 if want == "" {
72 panic("unauthorized: no delegate is configured for " + subject)
73 }
74 // The helper trusts its rlm input, so a future caller threading a stashed
75 // or sibling-frame realm value would otherwise bypass the path check below.
76 if !rlm.IsCurrent() {
77 panic("unauthorized: rlm is not the caller's live cur")
78 }
79 if rlm.Previous().PkgPath() != want {
80 panic("unauthorized: only " + want + " may write " + subject)
81 }
82}
83
84// assertDelegatePath rejects package paths that must never be stored in a
85// delegation slot, at the point a proposal is built rather than when it
86// executes.
87//
88// Rejecting the empty string is the same fail-open guard as in assertDelegate,
89// enforced on the way in as well so an accidental clear-by-empty-set cannot be
90// mistaken for a grant. Requiring the gno.land/r/ prefix excludes user accounts
91// (empty path), pure packages, and ephemeral `maketx run` realms — the last of
92// which matters because a run realm's path is non-empty, so a check based on
93// "is this code" would let one through.
94func assertDelegatePath(pkgpath string) {
95 if pkgpath == "" {
96 panic("invalid delegate: empty package path")
97 }
98 if !isRealmPath(pkgpath) {
99 panic("invalid delegate: must be a gno.land/r/ realm path, got " + pkgpath)
100 }
101 // Reject anything no caller could ever present, so a delegation cannot be
102 // silently dead: GovDAO would believe it granted the capability while the
103 // delegate is refused on every call. The sibling UpdateImpl rejects
104 // whitespace-padded entries for the same reason.
105 for i := 0; i < len(pkgpath); i++ {
106 c := pkgpath[i]
107 switch {
108 case c >= 'a' && c <= 'z', c >= '0' && c <= '9':
109 case c == '/' || c == '.' || c == '-' || c == '_' || c == '#':
110 default:
111 panic("invalid delegate: " + pkgpath +
112 " contains a character no package path or sub-identity can hold")
113 }
114 }
115 if n := countByte(pkgpath, '#'); n > 1 {
116 panic("invalid delegate: more than one '#' in " + pkgpath)
117 }
118}
119
120func countByte(s string, b byte) int {
121 n := 0
122 for i := 0; i < len(s); i++ {
123 if s[i] == b {
124 n++
125 }
126 }
127 return n
128}
129
130const realmPathPrefix = "gno.land/r/"
131
132func isRealmPath(pkgpath string) bool {
133 return len(pkgpath) > len(realmPathPrefix) &&
134 pkgpath[:len(realmPathPrefix)] == realmPathPrefix
135}
136
137// newDelegateProposal builds the GovDAO proposal that applies a delegation
138// change. apply mutates the slot and is run only when the vote passes.
139//
140// event is emitted from inside the executor, so an observer sees the delegation
141// change exactly when it takes effect rather than when it was proposed. from is
142// the previous holder ("" when none), included so a re-delegation is auditable
143// from the single event without reading prior state.
144// apply returns the holder it replaced, read at EXECUTION time. Capturing that
145// at creation would misreport a re-delegation: two set-proposals created while
146// the slot is empty, then executed in sequence, would both emit from="" even
147// though the second replaced the first.
148func newDelegateProposal(cur realm, event, key, to, title, desc string, apply func() string) dao.ProposalRequest {
149 callback := func(cur realm) error {
150 from := apply()
151 chain.Emit(event, "key", key, "from", from, "to", to)
152 return nil
153 }
154 return dao.NewProposalRequest(title, desc,
155 dao.NewSimpleExecutor(0, cur, callback, ""))
156}