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