proposal.gno
10.39 Kb · 280 lines
1package validators
2
3import (
4 "math/overflow"
5 "sort"
6 "strconv"
7 "strings"
8 "time"
9
10 "chain"
11
12 "gno.land/p/nt/ufmt/v0"
13 "gno.land/p/sys/validators"
14 "gno.land/r/gov/dao"
15 sysparams "gno.land/r/sys/params"
16)
17
18// ValoperChange is the operator-keyed input shape for the v3 valset
19// proposal builder. Power=0 removes; Power>0 adds (or upserts the
20// power on an op already in the active set — Tendermint's natural
21// ValidatorUpdate semantics).
22//
23// Each operator may appear AT MOST ONCE per proposal; duplicates are
24// rejected at create-time.
25type ValoperChange struct {
26 OperatorAddress address
27 Power uint64
28}
29
30func NewValoperChange(operatorAddress address, power uint64) ValoperChange {
31 return ValoperChange{
32 OperatorAddress: operatorAddress,
33 Power: power,
34 }
35}
36
37const errNoValoperChanges = "no valoper changes proposed"
38
39// NewValidatorProposalRequest builds a GovDAO proposal that, when
40// executed, applies the deltas to the chain's effective valset and
41// publishes the new full set via SetValsetProposal.
42//
43// NON-CROSSING (no `cur realm`). Direct MsgCall is unsupported;
44// proposers route through r/gnops/valopers/proposal's facade
45// (which IS crossing and accepts user txs).
46//
47// Validation at creation time:
48// - Each operator may appear AT MOST ONCE in changes; duplicates
49// panic. Power changes for an op already in the active set use
50// a single {op, newPower} entry (upsert), not the legacy
51// remove/re-add pair.
52// - Every ValoperChange's OperatorAddress must exist in
53// valoperCache. Unknown operators panic.
54// - Adds (Power > 0) require KeepRunning=true. An op that has
55// called UpdateKeepRunning(false) signals opt-out; no proposal
56// can keep them in the active set, period.
57//
58// Pubkey resolution at execution time: the executor callback
59// re-reads valoperCache for each entry to capture the CURRENT
60// signing pubkey/address — not the creation-time one. Defends
61// against a stale (now-retired) key publication if the operator
62// rotated while the proposal sat in GovDAO. Also re-checks
63// KeepRunning so an operator flipping to KeepRunning=false between
64// propose-create and propose-execute is honored. Removes are
65// unaffected (operator address is the lookup key, not signing
66// address).
67//
68// Emits ValidatorAdded / ValidatorRemoved events per entry on
69// successful execution. (Power-upsert on an existing op also emits
70// ValidatorAdded with the new power.)
71func NewValidatorProposalRequest(cur realm, changes []ValoperChange, title, description string) dao.ProposalRequest {
72 if len(changes) == 0 {
73 panic(errNoValoperChanges)
74 }
75 title = strings.TrimSpace(title)
76 if title == "" {
77 panic("proposal title is empty")
78 }
79 if len(changes) > 40 {
80 panic("max number of allowed validators per proposal is 40")
81 }
82
83 // Cooldown: refuse to even queue a proposal if the previous valset
84 // update happened too recently. The executor re-checks this — see
85 // the comment on lastValsetUpdate in limits.gno.
86 if time.Since(lastValsetUpdate) < valsetUpdateCooldown {
87 panic(errValsetUpdateCooldown)
88 }
89
90 // Dedupe: each operator may appear at most once per proposal.
91 // Power changes are now expressed as a single {op, newPower}
92 // upsert entry, so the legacy [{op,0},{op,N}] pair is a duplicate
93 // and rejected.
94 seen := map[string]bool{}
95 for _, c := range changes {
96 key := c.OperatorAddress.String()
97 if seen[key] {
98 panic("duplicate operator in proposal: " + key)
99 }
100 seen[key] = true
101 }
102
103 // Creation-time validation: every operator must exist in cache,
104 // and adds require KeepRunning=true. KeepRunning=false is a
105 // binding opt-out; no proposal shape can override it.
106 for _, c := range changes {
107 rawCache, ok := valoperCache.Get(c.OperatorAddress.String())
108 if !ok {
109 panic("unknown operator: " + c.OperatorAddress.String())
110 }
111 entry := rawCache.(cacheEntry)
112 if c.Power > 0 && !entry.KeepRunning {
113 panic("operator " + c.OperatorAddress.String() + " has KeepRunning=false; refusing to add (operator must call UpdateKeepRunning(true) first)")
114 }
115 }
116
117 // Render description against creation-time data. Voters see the
118 // operator addresses being proposed; signing addresses are an
119 // implementation detail resolved at exec.
120 var desc strings.Builder
121 desc.WriteString(description)
122 if len(description) > 0 {
123 desc.WriteString("\n\n")
124 }
125 desc.WriteString("## Validator Updates\n")
126 for _, c := range changes {
127 if c.Power == 0 {
128 desc.WriteString(ufmt.Sprintf("- %s: remove\n", c.OperatorAddress))
129 } else {
130 desc.WriteString(ufmt.Sprintf("- %s: add (power %d)\n", c.OperatorAddress, c.Power))
131 }
132 }
133
134 // Snapshot the trust-level ratio at proposal-creation time so the
135 // rule a proposal was screened against can't be relaxed between
136 // propose and execute. Without this snapshot a same-block sequence
137 // "trust-level-drop executor → valset-change executor" would let
138 // the second proposal pass under the looser ratio.
139 return dao.NewProposalRequest(title, desc.String(), newValoperChangeExecutor(cur, changes, trustLevelRatio))
140}
141
142// newValoperChangeExecutor builds the GovDAO executor that, on
143// approval, applies the captured ValoperChange deltas. Resolves
144// operator → signing addr/pubkey via valoperCache at execution time
145// for adds (so a mid-flight rotation doesn't publish a stale key).
146// Removes resolve the operator's CURRENT signing address (also via
147// cache) — operator-keyed removes are immune to rotation churn.
148//
149// Power>0 is an upsert against the effective valset map (keyed on
150// signing address): if the op is already present under that signing
151// address, the entry's voting power is overwritten. Tendermint
152// natively handles ValidatorUpdates as upserts, so a single-entry
153// power change is the canonical form.
154//
155// snapshotTrustLevel captures trustLevelRatio at proposal-creation
156// time. The IBC-trust-level rule is checked against this snapshot, not
157// the package-level trustLevelRatio that may have moved by exec time.
158func newValoperChangeExecutor(cur realm, changes []ValoperChange, snapshotTrustLevel trustRatio) dao.Executor {
159 callback := func(cur realm) error {
160 // Cooldown re-check: the binding source of truth. Creation-time
161 // only filters obvious failures; a proposal can sit in GovDAO
162 // while another proposal lands first.
163 if time.Since(lastValsetUpdate) < valsetUpdateCooldown {
164 panic(errValsetUpdateCooldown)
165 }
166
167 baseline := sysparams.GetValsetEffective()
168 set := make(map[address]validators.Validator, len(baseline))
169 // baselineByAddr captures the pre-update voting power keyed by
170 // signing address — used by the trust-level check below to
171 // measure how much of the previous set survived.
172 baselineByAddr := make(map[address]uint64, len(baseline))
173 var baselineTotal uint64
174 for _, v := range baseline {
175 set[v.Address] = v
176 baselineByAddr[v.Address] = v.VotingPower
177 baselineTotal += v.VotingPower
178 }
179
180 for _, c := range changes {
181 rawCache, ok := valoperCache.Get(c.OperatorAddress.String())
182 if !ok {
183 panic("operator vanished from valoperCache between propose and execute: " + c.OperatorAddress.String())
184 }
185 entry := rawCache.(cacheEntry)
186
187 if c.Power == 0 {
188 if _, ok := set[entry.SigningAddress]; !ok {
189 panic("validator does not exist: " + entry.SigningAddress.String())
190 }
191 delete(set, entry.SigningAddress)
192 chain.Emit(
193 "ValidatorRemoved",
194 "op", c.OperatorAddress.String(),
195 "signingAddr", entry.SigningAddress.String(),
196 )
197 continue
198 }
199
200 // Race-safety: operator may have flipped KeepRunning=false
201 // between propose-create and propose-execute. Re-check.
202 // The opt-out is binding regardless of proposal shape.
203 if !entry.KeepRunning {
204 panic("operator " + c.OperatorAddress.String() + " has KeepRunning=false at execution; refusing to add")
205 }
206
207 // Upsert at the current signing address. If the entry was
208 // already present (single-entry power change on an active
209 // validator), this overwrites the prior power.
210 set[entry.SigningAddress] = validators.Validator{
211 Address: entry.SigningAddress,
212 PubKey: entry.SigningPubKey,
213 VotingPower: c.Power,
214 }
215 chain.Emit(
216 "ValidatorAdded",
217 "op", c.OperatorAddress.String(),
218 "signingAddr", entry.SigningAddress.String(),
219 "power", strconv.FormatUint(c.Power, 10),
220 )
221 }
222
223 // Liveness floor: refuse to publish an empty set.
224 if len(set) == 0 {
225 panic("valset proposal would empty the validator set; refused to keep consensus liveness")
226 }
227
228 // Trust-level check (IBC light-client rule): the previous
229 // validator set must retain at least trustLevelRatio of its own
230 // voting power in the new set, so a light client at the old
231 // header can verify the new header.
232 //
233 // "Retained" is weighted by each survivor's BASELINE voting
234 // power, not the new one. This matches CometBFT's tally in
235 // types/validation.go:verifyCommitSingle — `talliedVotingPower
236 // += val.VotingPower` where `val` comes from the TRUSTED
237 // validator set, regardless of any new VP the same validator
238 // has in the untrusted header. Using new-VP here would create
239 // a false-positive accept: a proposal can remove most of the
240 // baseline and inflate a single survivor's VP to mask the
241 // loss; the light client (using baseline VPs) would refuse to
242 // verify the resulting header.
243 //
244 // Cross-multiply with overflow.Mulu64 so the comparison stays
245 // integer-deterministic.
246 var retainedFromBaseline uint64
247 for addr, baselineVP := range baselineByAddr {
248 if _, stillIn := set[addr]; stillIn {
249 retainedFromBaseline += baselineVP
250 }
251 }
252 if baselineTotal > 0 {
253 left, okL := overflow.Mulu64(retainedFromBaseline, snapshotTrustLevel.denominator)
254 right, okR := overflow.Mulu64(baselineTotal, snapshotTrustLevel.numerator)
255 if !okL || !okR {
256 panic(errTrustLevelOverflow)
257 }
258 // Strict inequality matches CometBFT's light-client check
259 // (types/validation.go:355,503): fail iff
260 // talliedVotingPower <= votingPowerNeeded. The boundary
261 // case (tallied * den == total * num) is REJECTED here so
262 // no chain-side-accepted update can leave an IBC light
263 // client unable to verify the next header.
264 if left <= right {
265 panic(errTrustLevelViolated)
266 }
267 }
268
269 entries := make([]string, 0, len(set))
270 for _, v := range set {
271 entries = append(entries, v.PubKey+":"+strconv.FormatUint(v.VotingPower, 10))
272 }
273 sort.Strings(entries)
274 sysparams.SetValsetProposal(cross(cur), entries)
275 lastValsetUpdate = time.Now()
276 return nil
277 }
278
279 return dao.NewSimpleExecutor(0, cur, callback, "")
280}