Search Apps Documentation Source Content File Folder Download Copy Actions Download

cache.gno

6.87 Kb · 190 lines
  1package validators
  2
  3import (
  4	"chain"
  5	"chain/runtime"
  6	"sort"
  7	"strconv"
  8
  9	"gno.land/p/nt/bptree/v0"
 10	"gno.land/p/sys/validators"
 11	sysparams "gno.land/r/sys/params"
 12)
 13
 14// valopersRealmPath is the only realm allowed to refresh valoperCache
 15// or invoke RotateValoperSigningKey. Both auth checks below depend on
 16// being called via `cross` from a crossing function in valopers.
 17const valopersRealmPath = "gno.land/r/gnops/valopers"
 18
 19// valoperCache mirrors the (operator -> current signing key) view from
 20// r/gnops/valopers. Written by valopers via NotifyValoperChanged. Read
 21// by future operator-keyed proposal flow (step 5).
 22//
 23// Pushing (valopers passes the values as args) rather than pulling
 24// (v3 imports valopers and reads them) — pulling would create an
 25// import cycle, since valopers already imports v3 for IsValidator.
 26var valoperCache = bptree.NewBPTree32()
 27
 28type cacheEntry struct {
 29	SigningPubKey  string
 30	SigningAddress address
 31	KeepRunning    bool
 32}
 33
 34// assertValopersCaller panics if the caller realm is not r/gnops/valopers.
 35// Per docs/resources/gno-interrealm.md, this check works only when (a)
 36// the host function is a crossing function (`cur realm`) and (b) it's
 37// invoked via `cross` from a crossing function in valopers. Then
 38// PreviousRealm() shifts exactly one frame to valopers. A user MsgCall
 39// would see PreviousRealm() == UserRealm (pkgpath ""); a third realm
 40// cross-call would see its own pkgpath. Either fails this check.
 41func assertValopersCaller() {
 42	caller := runtime.PreviousRealm().PkgPath()
 43	if caller != valopersRealmPath {
 44		panic("caller realm must be " + valopersRealmPath + ", got " + caller)
 45	}
 46}
 47
 48// NotifyValoperChanged refreshes the cached entry for op. Auth: caller
 49// realm must be r/gnops/valopers.
 50//
 51// READ-ONLY against valopers: by design this function does not call
 52// back into valopers (no pull). Valopers pushes the current values in
 53// as args. This eliminates the confused-deputy class where v3 → valopers
 54// callbacks would make valopers see v3 as PreviousRealm.
 55func NotifyValoperChanged(cur realm, op address, signingPubKey string, signingAddress address, keepRunning bool) {
 56	assertValopersCaller()
 57	valoperCache.Set(op.String(), cacheEntry{
 58		SigningPubKey:  signingPubKey,
 59		SigningAddress: signingAddress,
 60		KeepRunning:    keepRunning,
 61	})
 62}
 63
 64// RotateValoperSigningKey applies a signing-key rotation to the
 65// effective valset and publishes the new full set via sysparams.
 66// Auth: caller realm must be r/gnops/valopers.
 67//
 68// Body is read-modify-write against sysparams.GetValsetEffective so
 69// concurrent same-block writers (other rotations or GovDAO executors)
 70// accumulate instead of clobbering. Mirrors the executor pattern in
 71// validators.gno.
 72//
 73// Idempotent: if the rotating operator's old signing address is not
 74// currently in the effective valset (e.g., they were removed), the
 75// rotation is a no-op at the sysparams level — valopers' profile and
 76// signingRegistry already record the new key. Either replays cleanly.
 77//
 78// Emits ValoperRotated event with op + old/new addresses + height.
 79func RotateValoperSigningKey(cur realm, op address, oldPubKey, newPubKey string) {
 80	assertValopersCaller()
 81
 82	oldAddr, err := chain.PubKeyAddress(oldPubKey)
 83	if err != nil {
 84		panic("invalid oldPubKey: " + err.Error())
 85	}
 86	newAddr, err := chain.PubKeyAddress(newPubKey)
 87	if err != nil {
 88		panic("invalid newPubKey: " + err.Error())
 89	}
 90
 91	baseline := sysparams.GetValsetEffective()
 92	set := make(map[address]validators.Validator, len(baseline))
 93	for _, v := range baseline {
 94		set[v.Address] = v
 95	}
 96
 97	// The rotating operator must currently be in the active set; if
 98	// not (operator removed before rotating), nothing to publish.
 99	// Valopers-side state has already been updated regardless.
100	prev, ok := set[oldAddr]
101	if !ok {
102		chain.Emit(
103			"ValoperRotated",
104			"op", op.String(),
105			"oldAddr", oldAddr.String(),
106			"newAddr", newAddr.String(),
107			"height", strconv.FormatInt(runtime.ChainHeight(), 10),
108			"applied", "false",
109		)
110		return
111	}
112
113	delete(set, oldAddr)
114	set[newAddr] = validators.Validator{
115		Address:     newAddr,
116		PubKey:      newPubKey,
117		VotingPower: prev.VotingPower,
118	}
119
120	// Defense-in-depth: a delete+insert in this branch always leaves
121	// at least one entry (the freshly inserted newAddr), so an empty
122	// set is unreachable today. Panic explicitly anyway: a future
123	// refactor of this body that ends up publishing an empty set
124	// would otherwise be silently swallowed by the EndBlocker
125	// (which logs and clears dirty for empty publishes), masking
126	// the regression.
127	if len(set) == 0 {
128		panic("rotation would empty the validator set; refused to keep consensus liveness")
129	}
130
131	entries := make([]string, 0, len(set))
132	for _, v := range set {
133		entries = append(entries, v.PubKey+":"+strconv.FormatUint(v.VotingPower, 10))
134	}
135	sort.Strings(entries)
136	sysparams.SetValsetProposal(cross, entries)
137
138	chain.Emit(
139		"ValoperRotated",
140		"op", op.String(),
141		"oldAddr", oldAddr.String(),
142		"newAddr", newAddr.String(),
143		"height", strconv.FormatInt(runtime.ChainHeight(), 10),
144		"applied", "true",
145	)
146}
147
148// AssertGenesisValopersConsistent panics if any entry in valset:current
149// (the seeded genesis valset) lacks a corresponding valoperCache profile
150// whose SigningAddress matches.
151//
152// **Genesis-mode only.** The function refuses to run unless
153// runtime.ChainHeight() == 0. This is the documented intended usage
154// (last migration .jsonl tx, before any block has been produced) and
155// also closes a post-genesis MsgCall DoS surface — without the guard,
156// an attacker could pay gas to repeatedly invoke an O(N) iteration
157// over valoperCache + valset:current after the chain is live.
158//
159// gnoland's InitChainer auto-runs this assertion at end of
160// genesis-mode replay when GnoGenesisState.PastChainIDs is non-empty;
161// failure aborts the boot unconditionally. valoper-seed and
162// hand-crafted migration .jsonls do NOT need to emit the call
163// themselves.
164//
165// Crossing function: callable via MsgCall (only at genesis-mode).
166// Doesn't mutate state — pure invariant check. Inverse direction
167// (every valoperCache entry must have a corresponding valset:current
168// entry) is intentionally NOT checked: extra valoper profiles
169// registered without immediate valset inclusion are a normal
170// post-genesis state.
171func AssertGenesisValopersConsistent(cur realm) {
172	if runtime.ChainHeight() != 0 {
173		panic("AssertGenesisValopersConsistent is only callable during genesis-mode replay (ChainHeight()==0)")
174	}
175
176	// Collect the signing addresses present in valoperCache.
177	seen := map[string]bool{}
178	valoperCache.Iterate("", "", func(_ string, raw any) bool {
179		entry := raw.(cacheEntry)
180		seen[entry.SigningAddress.String()] = true
181		return false
182	})
183
184	// Every entry in valset:current must appear in seen.
185	for _, v := range sysparams.GetValsetEntries() {
186		if !seen[v.Address.String()] {
187			panic("genesis-validator " + v.Address.String() + " has no corresponding valoper profile (signing address not in valoperCache)")
188		}
189	}
190}