Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}