Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}