valopers.gno
17.17 Kb · 542 lines
1// Package valopers is designed around the permissionless lifecycle of valoper profiles.
2package valopers
3
4import (
5 "chain"
6 "chain/runtime"
7 "chain/runtime/unsafe"
8 "crypto/bech32"
9 "errors"
10 "regexp"
11
12 "gno.land/p/moul/realmpath"
13 "gno.land/p/nt/avl/v0"
14 "gno.land/p/nt/avl/v0/pager"
15 "gno.land/p/nt/bptree/v0"
16 "gno.land/p/nt/combinederr/v0"
17 "gno.land/p/nt/ownable/v0"
18 "gno.land/p/nt/ownable/v0/exts/authorizable"
19 "gno.land/p/nt/ufmt/v0"
20 sysparams "gno.land/r/sys/params"
21 validators "gno.land/r/sys/validators/v3"
22)
23
24const (
25 MonikerMaxLength = 32
26 DescriptionMaxLength = 2048
27
28 // Valid server types
29 ServerTypeCloud = "cloud"
30 ServerTypeOnPrem = "on-prem"
31 ServerTypeDataCenter = "data-center"
32)
33
34var (
35 ErrValoperExists = errors.New("valoper already exists")
36 ErrValoperMissing = errors.New("valoper does not exist")
37 ErrInvalidAddress = errors.New("invalid address")
38 ErrInvalidMoniker = errors.New("moniker is not valid")
39 ErrInvalidDescription = errors.New("description is not valid")
40 ErrInvalidServerType = errors.New("server type is not valid")
41 ErrOperatorSquatGuard = errors.New("post-genesis: caller must equal operator address")
42 ErrSigningKeyTaken = errors.New("signing address already in registry (active or retired)")
43 ErrFrontrunValidator = errors.New("post-genesis: signing address is already an active validator")
44 ErrRotationThrottled = errors.New("rotation throttled: try again later")
45 ErrRegistryEntryMissing = errors.New("signing address has no active registry entry (corrupted state)")
46)
47
48var (
49 valopers *avl.Tree // operator-address -> Valoper
50 instructions string // markdown instructions for valoper's registration
51
52 // signingRegistry maps SigningAddress.String() -> regEntry.
53 // Permanently retains retired entries to prevent key reuse and
54 // to support future slashing-attribution by signing address.
55 signingRegistry = bptree.NewBPTree32()
56
57 monikerMaxLengthMiddle = ufmt.Sprintf("%d", MonikerMaxLength-2)
58 validateMonikerRe = regexp.MustCompile(`^[a-zA-Z0-9][\w -]{0,` + monikerMaxLengthMiddle + `}[a-zA-Z0-9]$`) // 32 characters, including spaces, hyphens or underscores in the middle
59)
60
61// regEntry tracks signing-address -> operator with retirement metadata.
62// retiredAtHeight == 0 means the entry is currently active for the operator.
63type regEntry struct {
64 OperatorAddress address
65 RegisteredAtHeight int64
66 RetiredAtHeight int64
67}
68
69// Valoper represents a validator operator profile.
70type Valoper struct {
71 Moniker string // A human-readable name
72 Description string // A description and details about the valoper
73 ServerType string // The type of server (cloud/on-prem/data-center)
74
75 OperatorAddress address // operator identity, profile key, stable across rotations
76 SigningPubKey string // current consensus signing pubkey (bech32 gpub1...)
77 SigningAddress address // = chain.PubKeyAddress(SigningPubKey)
78
79 LastRotationHeight int64 // throttle anchor for UpdateSigningKey
80
81 KeepRunning bool // operator wants this validator running in the active set
82
83 auth *authorizable.Authorizable
84}
85
86func (v Valoper) Auth() *authorizable.Authorizable {
87 return v.auth
88}
89
90func AddToAuthList(cur realm, addr address, member address) {
91 v := GetByAddr(addr)
92 if err := v.Auth().AddToAuthList(0, cur, member); err != nil {
93 panic(err)
94 }
95}
96
97func DeleteFromAuthList(cur realm, addr address, member address) {
98 v := GetByAddr(addr)
99 if err := v.Auth().DeleteFromAuthList(0, cur, member); err != nil {
100 panic(err)
101 }
102}
103
104// Register registers a new valoper. The `addr` parameter is the
105// operator address (stable identity, profile key); `pubKey` is the
106// consensus signing pubkey, from which the signing address is derived.
107//
108// Auth shape:
109// - Post-genesis: OriginCaller must equal addr (operator-slot squat
110// guard). Genesis-mode replay (ChainHeight()==0) bypasses, so
111// migration .jsonl txs and historical Register replays succeed.
112// - Signing-address uniqueness: derived(pubKey) must not already be
113// in signingRegistry, active or retired.
114// - Front-running guard: post-genesis, derived(pubKey) must not
115// already be an active validator (a fresh registration cannot
116// squat on the consensus address of an existing validator).
117//
118// Why OriginCaller==addr is sufficient (no IsUserCall): squatting
119// requires the attacker to be able to satisfy OriginCaller==victim,
120// which requires the victim's signing key. r/sys/namereg/v1.Register
121// also gates on IsUserCall, but that's because IT reads
122// unsafe.OriginSend() for the anti-squatting payment and IsUserCall
123// is needed to ensure the OriginSend envelope reflects what landed at
124// this realm rather than a phantom payment from a previous frame.
125// valopers.Register has no per-call payment-receipt check (fees are
126// validated against banker.OriginSend in a way that's symmetric to
127// IsUserCall via direct comparison), so the IsUserCall tightening
128// would only block legitimate `maketx run` flows (operator-authored
129// scripts that legitimately set OriginCaller==operator) without
130// adding identity-squat protection.
131//
132// Auth-list seeding: the profile's Authorizable owner is set to addr
133// (NOT OriginCaller). At H>0 the squat guard makes them equal anyway;
134// at H==0 the deployer pattern (one signer registers many operators)
135// requires owner == addr so each operator can manage their own profile
136// post-genesis without needing the deployer's auth.
137func Register(cur realm, moniker string, description string, serverType string, addr address, pubKey string) {
138 // Operator-slot squat guard.
139 if runtime.ChainHeight() > 0 && unsafe.OriginCaller() != addr {
140 panic(ErrOperatorSquatGuard)
141 }
142
143 // Fee enforcement (read from sysparams; defaults to 0 until
144 // governance raises it post-transfer-enablement).
145 if fee := sysparams.GetValoperRegisterFee(); fee > 0 {
146 minFee := chain.NewCoin("ugnot", int64(fee))
147 sentCoins := unsafe.OriginSend()
148 if len(sentCoins) != 1 || sentCoins[0].IsLT(minFee) {
149 panic(ufmt.Sprintf("payment must not be less than %d%s", minFee.Amount, minFee.Denom))
150 }
151 }
152
153 // Check if the valoper is already registered.
154 if isValoper(addr) {
155 panic(ErrValoperExists)
156 }
157
158 // Derive the consensus signing address from the pubkey.
159 signingAddr, err := chain.PubKeyAddress(pubKey)
160 if err != nil {
161 panic(err)
162 }
163
164 // Signing-address uniqueness across all profiles, ever.
165 if signingRegistry.Has(signingAddr.String()) {
166 panic(ErrSigningKeyTaken)
167 }
168
169 // Front-running guard: post-genesis, the signing address must
170 // not already be an active validator.
171 if runtime.ChainHeight() > 0 && validators.IsValidator(signingAddr) {
172 panic(ErrFrontrunValidator)
173 }
174
175 v := Valoper{
176 Moniker: moniker,
177 Description: description,
178 ServerType: serverType,
179 OperatorAddress: addr,
180 SigningPubKey: pubKey,
181 SigningAddress: signingAddr,
182 LastRotationHeight: runtime.ChainHeight(),
183 KeepRunning: true,
184 auth: authorizable.New(ownable.NewWithAddress(addr)),
185 }
186
187 if err := v.Validate(); err != nil {
188 panic(err)
189 }
190
191 // Save the valoper to the set.
192 valopers.Set(v.OperatorAddress.String(), v)
193
194 // Insert into the signing-address registry.
195 signingRegistry.Set(signingAddr.String(), regEntry{
196 OperatorAddress: addr,
197 RegisteredAtHeight: runtime.ChainHeight(),
198 RetiredAtHeight: 0,
199 })
200
201 // Refresh v3's cache for this operator.
202 validators.NotifyValoperChanged(cross(cur), addr, v.SigningPubKey, v.SigningAddress, v.KeepRunning)
203}
204
205// UpdateMoniker updates an existing valoper's moniker.
206func UpdateMoniker(cur realm, addr address, moniker string) {
207 // Check that the moniker is not empty.
208 if err := validateMoniker(moniker); err != nil {
209 panic(err)
210 }
211
212 v := GetByAddr(addr)
213
214 // Check that the caller has permissions.
215 v.Auth().AssertPreviousOnAuthList(0, cur)
216
217 // Update the moniker.
218 v.Moniker = moniker
219
220 // Save the valoper info.
221 valopers.Set(addr.String(), v)
222}
223
224// UpdateDescription updates an existing valoper's description.
225func UpdateDescription(cur realm, addr address, description string) {
226 // Check that the description is not empty.
227 if err := validateDescription(description); err != nil {
228 panic(err)
229 }
230
231 v := GetByAddr(addr)
232
233 // Check that the caller has permissions.
234 v.Auth().AssertPreviousOnAuthList(0, cur)
235
236 // Update the description.
237 v.Description = description
238
239 // Save the valoper info.
240 valopers.Set(addr.String(), v)
241}
242
243// UpdateKeepRunning updates an existing valoper's active status.
244// Calls v3.NotifyValoperChanged because the cache stores KeepRunning.
245func UpdateKeepRunning(cur realm, addr address, keepRunning bool) {
246 v := GetByAddr(addr)
247
248 // Check that the caller has permissions.
249 v.Auth().AssertPreviousOnAuthList(0, cur)
250
251 // Update status.
252 v.KeepRunning = keepRunning
253
254 // Save the valoper info.
255 valopers.Set(addr.String(), v)
256
257 // Refresh v3's cache (KeepRunning is one of the cached fields).
258 validators.NotifyValoperChanged(cross(cur), addr, v.SigningPubKey, v.SigningAddress, v.KeepRunning)
259}
260
261// UpdateServerType updates an existing valoper's server type.
262func UpdateServerType(cur realm, addr address, serverType string) {
263 // Check that the server type is valid.
264 if err := validateServerType(serverType); err != nil {
265 panic(err)
266 }
267
268 v := GetByAddr(addr)
269
270 // Check that the caller has permissions.
271 v.Auth().AssertPreviousOnAuthList(0, cur)
272
273 // Update server type.
274 v.ServerType = serverType
275
276 // Save the valoper info.
277 valopers.Set(addr.String(), v)
278}
279
280// UpdateSigningKey rotates an operator's consensus signing key.
281//
282// Auth: caller must be on the operator's auth list (defaults to
283// operator at Register time; extendable via AddToAuthList).
284//
285// Invariants checked at entry:
286// - throttle: ChainHeight() - v.LastRotationHeight >=
287// rotationPeriodBlocks
288// - signingRegistry uniqueness: derived(newPubKey) not in registry
289// (active OR retired); permanently blocks key reuse
290// - fee: unsafe.OriginSend() >= rotationFee (mirrors Register's
291// fee-check pattern)
292//
293// Effect: profile's SigningPubKey/SigningAddress/LastRotationHeight
294// updated; old registry entry marked retired (retiredAtHeight =
295// ChainHeight()); new entry inserted into signingRegistry; v3 emits
296// remove+add to sysparams via RotateValoperSigningKey; v3 cache
297// refreshed via NotifyValoperChanged. Rotation lands in consensus
298// at H+2.
299//
300// Atomicity: Gno tx atomicity rolls back all state if any step
301// panics. If v3.RotateValoperSigningKey panics, the registry insert
302// and profile mutation revert with it.
303func UpdateSigningKey(cur realm, addr address, newPubKey string) {
304 v := GetByAddr(addr)
305
306 // Auth: caller must be on operator's auth list.
307 v.Auth().AssertPreviousOnAuthList(0, cur)
308
309 // Throttle: limit one rotation per rotation_period_blocks per
310 // operator (per profile, not per caller — multi-member auth lists
311 // can't multiplicative-rotate).
312 height := runtime.ChainHeight()
313 if height-v.LastRotationHeight < sysparams.GetValoperRotationPeriodBlocks() {
314 panic(ErrRotationThrottled)
315 }
316
317 // Fee: enforce only if non-zero (matches Register's pattern;
318 // rotation_fee defaults to zero pre-transfer-enablement).
319 if fee := sysparams.GetValoperRotationFee(); fee > 0 {
320 minFee := chain.NewCoin("ugnot", int64(fee))
321 sentCoins := unsafe.OriginSend()
322 if len(sentCoins) != 1 || sentCoins[0].IsLT(minFee) {
323 panic(ufmt.Sprintf("payment must not be less than %d%s", minFee.Amount, minFee.Denom))
324 }
325 }
326
327 // Derive the new signing address from the new pubkey.
328 newSigningAddr, err := chain.PubKeyAddress(newPubKey)
329 if err != nil {
330 panic(err)
331 }
332
333 // signingRegistry uniqueness: new key must not have ever been
334 // registered (active or retired).
335 if signingRegistry.Has(newSigningAddr.String()) {
336 panic(ErrSigningKeyTaken)
337 }
338
339 // Front-running guard: the derived signing address must not already
340 // be an active validator. Mirrors the same guard in Register
341 // (ErrFrontrunValidator). signingRegistry uniqueness above only
342 // blocks signing addresses that previously went through Register or
343 // UpdateSigningKey — genesis-seeded validators bypassed both, so
344 // their signing addresses are absent from signingRegistry. Without
345 // this check, a valoper could rotate onto such a slot and hijack
346 // it: v3.RotateValoperSigningKey would overwrite the active entry
347 // with this operator's claim, and a subsequent govDAO remove-op
348 // proposal would then delete it.
349 if validators.IsValidator(newSigningAddr) {
350 panic(ErrFrontrunValidator)
351 }
352
353 // Remember the previous signing key for the v3 cross-call.
354 oldPubKey := v.SigningPubKey
355 oldSigningAddr := v.SigningAddress
356
357 // Mark the old registry entry retired. The entry must exist —
358 // it was inserted at Register time.
359 rawOld, ok := signingRegistry.Get(oldSigningAddr.String())
360 if !ok {
361 panic(ErrRegistryEntryMissing)
362 }
363 oldEntry := rawOld.(regEntry)
364 oldEntry.RetiredAtHeight = height
365 signingRegistry.Set(oldSigningAddr.String(), oldEntry)
366
367 // Insert the new entry as active.
368 signingRegistry.Set(newSigningAddr.String(), regEntry{
369 OperatorAddress: addr,
370 RegisteredAtHeight: height,
371 RetiredAtHeight: 0,
372 })
373
374 // Update the profile.
375 v.SigningPubKey = newPubKey
376 v.SigningAddress = newSigningAddr
377 v.LastRotationHeight = height
378 valopers.Set(addr.String(), v)
379
380 // Apply to consensus via v3, then refresh v3's cache view of the
381 // profile. Order matters only in that both must complete; tx
382 // atomicity rolls back together on any panic.
383 validators.RotateValoperSigningKey(cross(cur), addr, oldPubKey, newPubKey)
384 validators.NotifyValoperChanged(cross(cur), addr, v.SigningPubKey, v.SigningAddress, v.KeepRunning)
385}
386
387// GetByAddr fetches the valoper using the operator address, if present.
388func GetByAddr(addr address) Valoper {
389 valoperRaw, exists := valopers.Get(addr.String())
390 if !exists {
391 panic(ErrValoperMissing)
392 }
393
394 return valoperRaw.(Valoper)
395}
396
397// Render renders the current valoper set.
398// "/r/gnops/valopers" lists all valopers, paginated.
399// "/r/gnops/valopers:addr" shows the detail for the valoper with the addr.
400func Render(fullPath string) string {
401 req := realmpath.Parse(fullPath)
402 if req.Path == "" {
403 return renderHome(fullPath)
404 } else {
405 addr := req.Path
406 if len(addr) < 2 || addr[:2] != "g1" {
407 return "invalid address " + addr
408 }
409 valoperRaw, exists := valopers.Get(addr)
410 if !exists {
411 return "unknown address " + addr
412 }
413 v := valoperRaw.(Valoper)
414 return "Valoper's details:\n" + v.Render()
415 }
416}
417
418func renderHome(path string) string {
419 // if there are no valopers, display instructions
420 if valopers.Size() == 0 {
421 return ufmt.Sprintf("%s\n\nNo valopers to display.", instructions)
422 }
423
424 page := pager.NewPager(valopers, 50, false).MustGetPageByPath(path)
425
426 output := ""
427
428 // if we are on the first page, display instructions
429 if page.PageNumber == 1 {
430 output += ufmt.Sprintf("%s\n\n", instructions)
431 }
432
433 for _, item := range page.Items {
434 v := item.Value.(Valoper)
435 output += ufmt.Sprintf(" * [%s](/r/gnops/valopers:%s) - [profile](/r/demo/profile:u/%s)\n",
436 v.Moniker, v.OperatorAddress, v.OperatorAddress)
437 }
438
439 output += "\n"
440 output += page.Picker(path)
441 return output
442}
443
444// Validate checks if the fields of the Valoper are valid.
445func (v *Valoper) Validate() error {
446 errs := &combinederr.CombinedError{}
447
448 errs.Add(validateMoniker(v.Moniker))
449 errs.Add(validateDescription(v.Description))
450 errs.Add(validateServerType(v.ServerType))
451 errs.Add(validateBech32(v.OperatorAddress))
452 errs.Add(validatePubKey(v.SigningPubKey))
453
454 if errs.Size() == 0 {
455 return nil
456 }
457
458 return errs
459}
460
461// Render renders a single valoper with their information.
462func (v Valoper) Render() string {
463 output := ufmt.Sprintf("## %s\n", v.Moniker)
464
465 if v.Description != "" {
466 output += ufmt.Sprintf("%s\n\n", v.Description)
467 }
468
469 output += ufmt.Sprintf("- Operator Address: %s\n", v.OperatorAddress.String())
470 output += ufmt.Sprintf("- Signing Address: %s\n", v.SigningAddress.String())
471 output += ufmt.Sprintf("- Signing PubKey: %s\n", v.SigningPubKey)
472 output += ufmt.Sprintf("- Server Type: %s\n\n", v.ServerType)
473 output += ufmt.Sprintf("[Profile link](/r/demo/profile:u/%s)\n", v.OperatorAddress)
474
475 return output
476}
477
478// isValoper checks if the valoper exists.
479func isValoper(addr address) bool {
480 _, exists := valopers.Get(addr.String())
481
482 return exists
483}
484
485// validateMoniker checks if the moniker is valid.
486func validateMoniker(moniker string) error {
487 if moniker == "" {
488 return ErrInvalidMoniker
489 }
490
491 if len(moniker) > MonikerMaxLength {
492 return ErrInvalidMoniker
493 }
494
495 if !validateMonikerRe.MatchString(moniker) {
496 return ErrInvalidMoniker
497 }
498
499 return nil
500}
501
502// validateDescription checks if the description is valid.
503func validateDescription(description string) error {
504 if description == "" {
505 return ErrInvalidDescription
506 }
507
508 if len(description) > DescriptionMaxLength {
509 return ErrInvalidDescription
510 }
511
512 return nil
513}
514
515// validateBech32 checks if the value is a valid bech32 address.
516func validateBech32(addr address) error {
517 if !addr.IsValid() {
518 return ErrInvalidAddress
519 }
520
521 return nil
522}
523
524// validatePubKey checks if the public key is valid.
525func validatePubKey(pubKey string) error {
526 if _, _, err := bech32.DecodeNoLimit(pubKey); err != nil {
527 return err
528 }
529
530 return nil
531}
532
533// validateServerType checks if the server type is valid.
534func validateServerType(serverType string) error {
535 if serverType != ServerTypeCloud &&
536 serverType != ServerTypeOnPrem &&
537 serverType != ServerTypeDataCenter {
538 return ErrInvalidServerType
539 }
540
541 return nil
542}