Search Apps Documentation Source Content File Folder Download Copy Actions Download 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"
 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}