proposal.gno
7.17 Kb · 206 lines
1package validators
2
3import (
4 "sort"
5 "strconv"
6 "strings"
7
8 "chain"
9
10 "gno.land/p/nt/ufmt/v0"
11 "gno.land/p/sys/validators"
12 "gno.land/r/gov/dao"
13 sysparams "gno.land/r/sys/params"
14)
15
16// ValoperChange is the operator-keyed input shape for the v3 valset
17// proposal builder. Power=0 removes; Power>0 adds (or upserts the
18// power on an op already in the active set — Tendermint's natural
19// ValidatorUpdate semantics).
20//
21// Each operator may appear AT MOST ONCE per proposal; duplicates are
22// rejected at create-time.
23type ValoperChange struct {
24 OperatorAddress address
25 Power uint64
26}
27
28func NewValoperChange(operatorAddress address, power uint64) ValoperChange {
29 return ValoperChange{
30 OperatorAddress: operatorAddress,
31 Power: power,
32 }
33}
34
35const errNoValoperChanges = "no valoper changes proposed"
36
37// NewValidatorProposalRequest builds a GovDAO proposal that, when
38// executed, applies the deltas to the chain's effective valset and
39// publishes the new full set via SetValsetProposal.
40//
41// NON-CROSSING (no `cur realm`). Direct MsgCall is unsupported;
42// proposers route through r/gnops/valopers/proposal's facade
43// (which IS crossing and accepts user txs).
44//
45// Validation at creation time:
46// - Each operator may appear AT MOST ONCE in changes; duplicates
47// panic. Power changes for an op already in the active set use
48// a single {op, newPower} entry (upsert), not the legacy
49// remove/re-add pair.
50// - Every ValoperChange's OperatorAddress must exist in
51// valoperCache. Unknown operators panic.
52// - Adds (Power > 0) require KeepRunning=true. An op that has
53// called UpdateKeepRunning(false) signals opt-out; no proposal
54// can keep them in the active set, period.
55//
56// Pubkey resolution at execution time: the executor callback
57// re-reads valoperCache for each entry to capture the CURRENT
58// signing pubkey/address — not the creation-time one. Defends
59// against a stale (now-retired) key publication if the operator
60// rotated while the proposal sat in GovDAO. Also re-checks
61// KeepRunning so an operator flipping to KeepRunning=false between
62// propose-create and propose-execute is honored. Removes are
63// unaffected (operator address is the lookup key, not signing
64// address).
65//
66// Emits ValidatorAdded / ValidatorRemoved events per entry on
67// successful execution. (Power-upsert on an existing op also emits
68// ValidatorAdded with the new power.)
69func NewValidatorProposalRequest(cur realm, changes []ValoperChange, title, description string) dao.ProposalRequest {
70 if len(changes) == 0 {
71 panic(errNoValoperChanges)
72 }
73 title = strings.TrimSpace(title)
74 if title == "" {
75 panic("proposal title is empty")
76 }
77 if len(changes) > 40 {
78 panic("max number of allowed validators per proposal is 40")
79 }
80
81 // Dedupe: each operator may appear at most once per proposal.
82 // Power changes are now expressed as a single {op, newPower}
83 // upsert entry, so the legacy [{op,0},{op,N}] pair is a duplicate
84 // and rejected.
85 seen := map[string]bool{}
86 for _, c := range changes {
87 key := c.OperatorAddress.String()
88 if seen[key] {
89 panic("duplicate operator in proposal: " + key)
90 }
91 seen[key] = true
92 }
93
94 // Creation-time validation: every operator must exist in cache,
95 // and adds require KeepRunning=true. KeepRunning=false is a
96 // binding opt-out; no proposal shape can override it.
97 for _, c := range changes {
98 rawCache := valoperCache.Get(c.OperatorAddress.String())
99 if rawCache == nil {
100 panic("unknown operator: " + c.OperatorAddress.String())
101 }
102 entry := rawCache.(cacheEntry)
103 if c.Power > 0 && !entry.KeepRunning {
104 panic("operator " + c.OperatorAddress.String() + " has KeepRunning=false; refusing to add (operator must call UpdateKeepRunning(true) first)")
105 }
106 }
107
108 // Render description against creation-time data. Voters see the
109 // operator addresses being proposed; signing addresses are an
110 // implementation detail resolved at exec.
111 var desc strings.Builder
112 desc.WriteString(description)
113 if len(description) > 0 {
114 desc.WriteString("\n\n")
115 }
116 desc.WriteString("## Validator Updates\n")
117 for _, c := range changes {
118 if c.Power == 0 {
119 desc.WriteString(ufmt.Sprintf("- %s: remove\n", c.OperatorAddress))
120 } else {
121 desc.WriteString(ufmt.Sprintf("- %s: add (power %d)\n", c.OperatorAddress, c.Power))
122 }
123 }
124
125 return dao.NewProposalRequest(title, desc.String(), newValoperChangeExecutor(cur, changes))
126}
127
128// newValoperChangeExecutor builds the GovDAO executor that, on
129// approval, applies the captured ValoperChange deltas. Resolves
130// operator → signing addr/pubkey via valoperCache at execution time
131// for adds (so a mid-flight rotation doesn't publish a stale key).
132// Removes resolve the operator's CURRENT signing address (also via
133// cache) — operator-keyed removes are immune to rotation churn.
134//
135// Power>0 is an upsert against the effective valset map (keyed on
136// signing address): if the op is already present under that signing
137// address, the entry's voting power is overwritten. Tendermint
138// natively handles ValidatorUpdates as upserts, so a single-entry
139// power change is the canonical form.
140func newValoperChangeExecutor(cur realm, changes []ValoperChange) dao.Executor {
141 callback := func(cur realm) error {
142 baseline := sysparams.GetValsetEffective()
143 set := make(map[address]validators.Validator, len(baseline))
144 for _, v := range baseline {
145 set[v.Address] = v
146 }
147
148 for _, c := range changes {
149 rawCache := valoperCache.Get(c.OperatorAddress.String())
150 if rawCache == nil {
151 panic("operator vanished from valoperCache between propose and execute: " + c.OperatorAddress.String())
152 }
153 entry := rawCache.(cacheEntry)
154
155 if c.Power == 0 {
156 if _, ok := set[entry.SigningAddress]; !ok {
157 panic("validator does not exist: " + entry.SigningAddress.String())
158 }
159 delete(set, entry.SigningAddress)
160 chain.Emit(
161 "ValidatorRemoved",
162 "op", c.OperatorAddress.String(),
163 "signingAddr", entry.SigningAddress.String(),
164 )
165 continue
166 }
167
168 // Race-safety: operator may have flipped KeepRunning=false
169 // between propose-create and propose-execute. Re-check.
170 // The opt-out is binding regardless of proposal shape.
171 if !entry.KeepRunning {
172 panic("operator " + c.OperatorAddress.String() + " has KeepRunning=false at execution; refusing to add")
173 }
174
175 // Upsert at the current signing address. If the entry was
176 // already present (single-entry power change on an active
177 // validator), this overwrites the prior power.
178 set[entry.SigningAddress] = validators.Validator{
179 Address: entry.SigningAddress,
180 PubKey: entry.SigningPubKey,
181 VotingPower: c.Power,
182 }
183 chain.Emit(
184 "ValidatorAdded",
185 "op", c.OperatorAddress.String(),
186 "signingAddr", entry.SigningAddress.String(),
187 "power", strconv.FormatUint(c.Power, 10),
188 )
189 }
190
191 // Liveness floor: refuse to publish an empty set.
192 if len(set) == 0 {
193 panic("valset proposal would empty the validator set; refused to keep consensus liveness")
194 }
195
196 entries := make([]string, 0, len(set))
197 for _, v := range set {
198 entries = append(entries, v.PubKey+":"+strconv.FormatUint(v.VotingPower, 10))
199 }
200 sort.Strings(entries)
201 sysparams.SetValsetProposal(cross(cur), entries)
202 return nil
203 }
204
205 return dao.NewSimpleExecutor(0, cur, callback, "")
206}