admin.gno
7.94 Kb · 233 lines
1package users
2
3import (
4 "chain"
5 "chain/runtime"
6
7 "gno.land/p/moul/addrset"
8 "gno.land/p/nt/ufmt/v0"
9
10 "gno.land/r/gov/dao"
11)
12
13const initControllerPath = "gno.land/r/sys/users/init"
14
15var controllers = addrset.Set{} // caller whitelist
16
17func init() {
18 // auto-whitelist the init controller for bootstrapping for testing chain.
19 if chainID := runtime.ChainID(); chainID == "dev" {
20 controllers.Add(chain.PackageAddress(initControllerPath))
21 }
22}
23
24// AddControllerAtGenesis allows adding a controller during chain genesis (height 0).
25// This is mostly useful for testing.
26func AddControllerAtGenesis(_ realm, addr address) {
27 height := runtime.ChainHeight()
28 if height > 0 {
29 panic("AddControllerAtGenesis can only be called at genesis (height 0)")
30 }
31
32 if !addr.IsValid() {
33 panic(ErrInvalidAddress)
34 }
35
36 controllers.Add(addr)
37}
38
39// ProposeNewController allows GovDAO to add a whitelisted caller
40func ProposeNewController(cur realm, addr address) dao.ProposalRequest {
41 if !addr.IsValid() {
42 panic(ErrInvalidAddress)
43 }
44
45 cb := func(cur realm) error {
46 return addToWhitelist(addr)
47 }
48
49 desc := "This proposal adds " + addr.String() + " to `sys/users` realm's callers whitelist."
50 return dao.NewProposalRequest("Add Whitelisted Caller to \"sys/users\" Realm", desc, dao.NewSimpleExecutor(0, cur, cb, ""))
51}
52
53// ProposeControllerRemoval allows GovDAO to add a whitelisted caller
54func ProposeControllerRemoval(cur realm, addr address) dao.ProposalRequest {
55 if !addr.IsValid() {
56 panic(ErrInvalidAddress)
57 }
58
59 cb := func(cur realm) error {
60 return deleteFromWhitelist(addr)
61 }
62
63 desc := "This proposal removes " + addr.String() + " from `sys/users` realm's callers whitelist."
64 return dao.NewProposalRequest("Remove Whitelisted Caller From \"sys/users\" Realm", desc, dao.NewSimpleExecutor(0, cur, cb, ""))
65}
66
67// ProposeControllerAdditionAndRemoval allows GovDAO to add a new caller and remove an old caller in the same proposal.
68func ProposeControllerAdditionAndRemoval(cur realm, toAdd, toRemove address) dao.ProposalRequest {
69 if !toAdd.IsValid() || !toRemove.IsValid() {
70 panic(ErrInvalidAddress)
71 }
72
73 cb := func(cur realm) error {
74 return applyControllerSwap(toAdd, toRemove)
75 }
76
77 desc := ufmt.Sprintf(
78 "This proposal adds %s and removes %s from `sys/users` realm's callers whitelist.",
79 toAdd,
80 toRemove,
81 )
82 return dao.NewProposalRequest("Add and Remove Whitelisted Callers From \"sys/users\" Realm", desc, dao.NewSimpleExecutor(0, cur, cb, ""))
83}
84
85// applyControllerSwap is the callback body of ProposeControllerAdditionAndRemoval,
86// extracted so it can be unit-tested without driving the full GovDAO flow.
87//
88// The desired end state is "toAdd is in the whitelist AND toRemove is out".
89// Both operations are made idempotent so the swap doesn't silently no-op when
90// the chain state has drifted between proposal creation and execution:
91//
92// - If toAdd is already whitelisted, treat addToWhitelist's
93// ErrAlreadyWhitelisted as benign and continue to the remove step.
94// - If toRemove is already absent, treat deleteFromWhitelist's
95// ErrNotWhitelisted as benign and return success.
96//
97// Without this idempotency, the original code returned early on an "already
98// whitelisted" toAdd and skipped the remove entirely — a swap proposal could
99// pass governance and silently leave the old controller active. (audit
100// finding #6)
101func applyControllerSwap(toAdd, toRemove address) error {
102 if err := addToWhitelist(toAdd); err != nil && err != ErrAlreadyWhitelisted {
103 return err
104 }
105 if err := deleteFromWhitelist(toRemove); err != nil {
106 if _, alreadyOut := err.(ErrNotWhitelisted); !alreadyOut {
107 return err
108 }
109 }
110 return nil
111}
112
113// ProposeRegisterUser allows GovDAO to register a name without checking
114// controllers. The executor closure runs with ignoreCanonical=true (decision
115// #3): DAO grants always bypass canonical-collision detection. Voters see
116// any collision in the proposal description and can vote NO if unintended.
117func ProposeRegisterUser(cur realm, name string, addr address) dao.ProposalRequest {
118 // Validate the name and address now, even though registerUser will validate again
119 if err := validateName(name); err != nil {
120 panic(err.Error())
121 }
122 if !addr.IsValid() {
123 panic(ErrInvalidAddress)
124 }
125
126 desc := "This proposal registers " + name + " with address " + addr.String() + " in `sys/users`."
127 if existing, taken := IsCanonicalTaken(name); taken && existing != name {
128 desc += "\n\nCANONICAL COLLISION: this name's canonical form matches the existing registration of `" +
129 existing + "`. DAO grants bypass canonical-collision detection — the proposal will succeed if voted in. " +
130 "If the collision is unintended, vote NO."
131 }
132
133 cb := func(cur realm) error {
134 return registerUser(cur, name, addr, true) // bypass canonical (decision #3)
135 }
136
137 return dao.NewProposalRequest("Register User to \"sys/users\" Realm", desc, dao.NewSimpleExecutor(0, cur, cb, ""))
138}
139
140// ProposeUpdateName allows GovDAO to update a name with an alias without
141// checking controllers. Like ProposeRegisterUser, the executor runs with
142// ignoreCanonical=true (decision #3).
143func ProposeUpdateName(cur realm, addr address, newName string) dao.ProposalRequest {
144 if !addr.IsValid() {
145 panic(ErrInvalidAddress)
146 }
147 if err := validateName(newName); err != nil {
148 panic(err.Error())
149 }
150
151 desc := "This proposal updates address " + addr.String() + " with alias " + newName + " in `sys/users`."
152 if existing, taken := IsCanonicalTaken(newName); taken && existing != newName {
153 desc += "\n\nCANONICAL COLLISION: the new alias's canonical form matches the existing registration of `" +
154 existing + "`. DAO grants bypass canonical-collision detection — the proposal will succeed if voted in. " +
155 "If the collision is unintended, vote NO."
156 }
157
158 cb := func(cur realm) error {
159 data := ResolveAddress(addr)
160 if data == nil {
161 return ErrUserNotExistOrDeleted
162 }
163 return data.updateName(newName, true) // bypass canonical (decision #3)
164 }
165
166 return dao.NewProposalRequest("Update Name Alias in \"sys/users\" Realm", desc, dao.NewSimpleExecutor(0, cur, cb, ""))
167}
168
169// ProposeDeleteUser allows GovDAO to delete a user without checking controllers
170func ProposeDeleteUser(cur realm, addr address) dao.ProposalRequest {
171 if !addr.IsValid() {
172 panic(ErrInvalidAddress)
173 }
174
175 cb := func(cur realm) error {
176 data := ResolveAddress(addr)
177 if data == nil {
178 return ErrUserNotExistOrDeleted
179 }
180 return data.delete()
181 }
182
183 desc := "This proposal deletes the user with address " + addr.String() + " in `sys/users`."
184 return dao.NewProposalRequest("Delete User in \"sys/users\" Realm", desc, dao.NewSimpleExecutor(0, cur, cb, ""))
185}
186
187// IsController reports whether the given address is currently in the
188// controller whitelist. Returns the same boolean that gating checks
189// (RegisterUser, UpdateName, Delete) use internally — useful for
190// off-chain monitoring and for governance proposals to inspect state
191// before voting.
192func IsController(addr address) bool {
193 return controllers.Has(addr)
194}
195
196// Controllers returns a snapshot of the current controller whitelist.
197// The returned slice is a fresh copy; mutating it does not affect realm
198// state. Order is the iteration order of the underlying address set.
199//
200// Audit finding #20: without this getter, the controller whitelist was
201// opaque from outside the package — operators had to read source or
202// replay every governance proposal to know who could write to the
203// registry. This is the read-only API that closes that gap.
204func Controllers() []address {
205 out := make([]address, 0, controllers.Size())
206 controllers.IterateByOffset(0, controllers.Size(), func(a address) bool {
207 out = append(out, a)
208 return false
209 })
210 return out
211}
212
213// Helpers
214
215func deleteFromWhitelist(addr address) error {
216 if !controllers.Has(addr) {
217 return ErrNotWhitelisted{Caller: "UserRealm{ " + addr.String() + " }"}
218 }
219
220 if ok := controllers.Remove(addr); !ok {
221 return ErrWhitelistRemoveFailed
222 }
223
224 return nil
225}
226
227func addToWhitelist(newCaller address) error {
228 if !controllers.Add(newCaller) {
229 return ErrAlreadyWhitelisted
230 }
231
232 return nil
233}