Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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}