Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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/v0"
 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 v0 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}