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