Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}