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}