Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}