Search Apps Documentation Source Content File Folder Download Copy Actions Download

proposal_test.gno

18.30 Kb · 586 lines
  1package validators
  2
  3import (
  4	"strconv"
  5	"testing"
  6
  7	"gno.land/p/nt/testutils/v0"
  8	"gno.land/p/nt/uassert/v0"
  9	"gno.land/p/nt/urequire/v0"
 10	sysparams "gno.land/r/sys/params"
 11)
 12
 13// seedCache populates valoperCache with the given (op, pubkey, kr)
 14// tuples — used in tests to satisfy NewValidatorProposalRequest's
 15// creation-time membership check without going through valopers.
 16func seedCache(t *testing.T, entries []struct {
 17	op          address
 18	pubKey      string
 19	keepRunning bool
 20}) {
 21	t.Helper()
 22	for _, e := range entries {
 23		signingAddr := mustAddr(t, e.pubKey)
 24		valoperCache.Set(e.op.String(), cacheEntry{
 25			SigningPubKey:  e.pubKey,
 26			SigningAddress: signingAddr,
 27			KeepRunning:    e.keepRunning,
 28		})
 29	}
 30}
 31
 32func TestNewValidatorProposalRequest_RejectsUnknownOperator(t *testing.T) {
 33	resetCache()
 34	resetLimits()
 35
 36	op := testutils.TestAddress("ghost-op")
 37
 38	uassert.PanicsContains(t, "unknown operator", func() {
 39		_ = NewValidatorProposalRequest(
 40			[]ValoperChange{{OperatorAddress: op, Power: 1}},
 41			"add ghost",
 42			"",
 43		)
 44	})
 45}
 46
 47func TestNewValidatorProposalRequest_RejectsEmptyChanges(t *testing.T) {
 48	resetCache()
 49	resetLimits()
 50
 51	uassert.PanicsContains(t, errNoValoperChanges, func() {
 52		_ = NewValidatorProposalRequest(nil, "title", "")
 53	})
 54}
 55
 56func TestNewValidatorProposalRequest_RejectsEmptyTitle(t *testing.T) {
 57	resetCache()
 58	resetLimits()
 59	op := testutils.TestAddress("op-A")
 60	seedCache(t, []struct {
 61		op          address
 62		pubKey      string
 63		keepRunning bool
 64	}{{op: op, pubKey: pubKeyA, keepRunning: true}})
 65
 66	uassert.PanicsContains(t, "proposal title is empty", func() {
 67		_ = NewValidatorProposalRequest(
 68			[]ValoperChange{{OperatorAddress: op, Power: 1}},
 69			"   ",
 70			"",
 71		)
 72	})
 73}
 74
 75func TestNewValidatorProposalRequest_RejectsTooManyChanges(t *testing.T) {
 76	resetCache()
 77	resetLimits()
 78
 79	// Seed 41 cache entries so the membership check passes; the
 80	// length cap should fire before the per-entry validation.
 81	changes := make([]ValoperChange, 41)
 82	pubkeys := []string{pubKeyA, pubKeyB, pubKeyC}
 83	for i := 0; i < 41; i++ {
 84		op := testutils.TestAddress("op-" + strconv.Itoa(i))
 85		pk := pubkeys[i%3]
 86		valoperCache.Set(op.String(), cacheEntry{
 87			SigningPubKey:  pk,
 88			SigningAddress: mustAddr(t, pk),
 89			KeepRunning:    true,
 90		})
 91		changes[i] = ValoperChange{OperatorAddress: op, Power: 1}
 92	}
 93
 94	uassert.PanicsContains(t, "max number of allowed validators per proposal is 40", func() {
 95		_ = NewValidatorProposalRequest(changes, "too many", "")
 96	})
 97}
 98
 99func TestNewValidatorProposalRequest_DescriptionRendering(t *testing.T) {
100	resetCache()
101	resetLimits()
102	opA := testutils.TestAddress("op-A")
103	opB := testutils.TestAddress("op-B")
104	seedCache(t, []struct {
105		op          address
106		pubKey      string
107		keepRunning bool
108	}{
109		{op: opA, pubKey: pubKeyA, keepRunning: true},
110		{op: opB, pubKey: pubKeyB, keepRunning: true},
111	})
112
113	pr := NewValidatorProposalRequest(
114		[]ValoperChange{
115			{OperatorAddress: opA, Power: 5},
116			{OperatorAddress: opB, Power: 0},
117		},
118		"mixed changes",
119		"context line",
120	)
121
122	desc := pr.Description()
123	urequire.True(t, len(desc) > 0)
124	uassert.True(t, contains(desc, "context line"))
125	uassert.True(t, contains(desc, "## Validator Updates"))
126	uassert.True(t, contains(desc, opA.String()+": add (power 5)"))
127	uassert.True(t, contains(desc, opB.String()+": remove"))
128}
129
130func TestNewValidatorProposalRequest_ExecutorReResolvesPubkey(t *testing.T) {
131	// Creation-time captured changes: ValoperChange refers to opA.
132	// Cache for opA points to pubKeyA at creation. Before execution,
133	// opA's cache entry is updated to pubKeyB. Executor must publish
134	// the NEW pubkey, not the creation-time one.
135	resetValset(t)
136	resetCache()
137	resetLimits()
138
139	opA := testutils.TestAddress("op-A")
140	seedCache(t, []struct {
141		op          address
142		pubKey      string
143		keepRunning bool
144	}{{op: opA, pubKey: pubKeyA, keepRunning: true}})
145
146	changes := []ValoperChange{{OperatorAddress: opA, Power: 7}}
147
148	// Build the executor; it captures `changes` by reference (slice
149	// of structs) but resolves SigningPubKey at run-time via cache.
150	exec := newValoperChangeExecutor(changes, trustLevelRatio)
151
152	// Simulate operator rotation: opA's cache entry now points to
153	// pubKeyB. Captured changes slice is unchanged.
154	valoperCache.Set(opA.String(), cacheEntry{
155		SigningPubKey:  pubKeyB,
156		SigningAddress: mustAddr(t, pubKeyB),
157		KeepRunning:    true,
158	})
159
160	urequire.NoError(t, exec.Execute(cross))
161
162	// Effective valset should contain pubKeyB (post-rotation), not
163	// pubKeyA (creation-time).
164	effective := sysparams.GetValsetEffective()
165	urequire.Equal(t, 1, len(effective))
166	uassert.Equal(t, pubKeyB, effective[0].PubKey)
167	uassert.Equal(t, uint64(7), effective[0].VotingPower)
168}
169
170func TestNewValidatorProposalRequest_RemoveOperator(t *testing.T) {
171	resetValset(t)
172	resetCache()
173	resetLimits()
174
175	// Seed valset with opA already signing under pubKeyA.
176	testing.SetSysParamStrings(module, submodule, currKey, []string{pubKeyA + ":10"})
177
178	opA := testutils.TestAddress("op-A")
179	seedCache(t, []struct {
180		op          address
181		pubKey      string
182		keepRunning bool
183	}{{op: opA, pubKey: pubKeyA, keepRunning: false}})
184
185	// Liveness floor: removing the only validator empties the set.
186	// Executor runs inside a crossing dao.Executor.Execute call, so
187	// the panic surfaces as an abort, not a regular panic.
188	uassert.AbortsContains(t, "would empty the validator set", func() {
189		_ = newValoperChangeExecutor([]ValoperChange{{OperatorAddress: opA, Power: 0}}, trustLevelRatio).Execute(cross)
190	})
191}
192
193func TestNewValidatorProposalRequest_RemoveLeavesOthers(t *testing.T) {
194	resetValset(t)
195	resetCache()
196	resetLimits()
197
198	// Seed valset with two validators. B holds 11/16 ≈ 68% so
199	// removing A (5/16 ≈ 31%) leaves retained = 11, which is well
200	// above the 1/3 trust threshold (16/3 ≈ 5.33) — the trust check
201	// passes and the test isolates the removal-mechanics assertion.
202	testing.SetSysParamStrings(module, submodule, currKey, []string{
203		pubKeyA + ":5",
204		pubKeyB + ":11",
205	})
206
207	opA := testutils.TestAddress("op-A")
208	opB := testutils.TestAddress("op-B")
209	seedCache(t, []struct {
210		op          address
211		pubKey      string
212		keepRunning bool
213	}{
214		{op: opA, pubKey: pubKeyA, keepRunning: true},
215		{op: opB, pubKey: pubKeyB, keepRunning: true},
216	})
217
218	changes := []ValoperChange{{OperatorAddress: opA, Power: 0}}
219	// Build the executor directly (private function, same package).
220	urequire.NoError(t, newValoperChangeExecutor(changes, trustLevelRatio).Execute(cross))
221
222	// Effective set: only opB / pubKeyB remains.
223	effective := sysparams.GetValsetEffective()
224	urequire.Equal(t, 1, len(effective))
225	uassert.Equal(t, pubKeyB, effective[0].PubKey)
226}
227
228func TestNewValidatorProposalRequest_RejectsKeepRunningFalseAtCreation(t *testing.T) {
229	resetCache()
230	resetLimits()
231	op := testutils.TestAddress("op-A")
232	seedCache(t, []struct {
233		op          address
234		pubKey      string
235		keepRunning bool
236	}{{op: op, pubKey: pubKeyA, keepRunning: false}})
237
238	uassert.PanicsContains(t, "KeepRunning=false", func() {
239		_ = NewValidatorProposalRequest(
240			[]ValoperChange{{OperatorAddress: op, Power: 1}},
241			"add opted-out", "",
242		)
243	})
244}
245
246func TestNewValidatorProposalRequest_AllowsRemoveOfKeepRunningFalse(t *testing.T) {
247	// KeepRunning=false is the operator's opt-out signal; removing
248	// such an operator must still be allowed (it's the standard
249	// exit path). Only adds are gated.
250	resetValset(t)
251	resetCache()
252	resetLimits()
253
254	// Seed the valset with two operators so removing one doesn't
255	// trip the empty-valset liveness floor. B holds 11/16 ≈ 68% so
256	// removing A keeps retained = 11 well above the 1/3 trust
257	// threshold (16/3 ≈ 5.33).
258	testing.SetSysParamStrings(module, submodule, currKey, []string{
259		pubKeyA + ":5",
260		pubKeyB + ":11",
261	})
262
263	opA := testutils.TestAddress("op-A")
264	opB := testutils.TestAddress("op-B")
265	seedCache(t, []struct {
266		op          address
267		pubKey      string
268		keepRunning bool
269	}{
270		{op: opA, pubKey: pubKeyA, keepRunning: false}, // opted out
271		{op: opB, pubKey: pubKeyB, keepRunning: true},
272	})
273
274	// Build proposal succeeds (remove path: Power=0 ignores KeepRunning).
275	pr := NewValidatorProposalRequest(
276		[]ValoperChange{{OperatorAddress: opA, Power: 0}},
277		"remove opted-out opA", "",
278	)
279	_ = pr
280
281	// Executor also succeeds.
282	urequire.NoError(t, newValoperChangeExecutor([]ValoperChange{{OperatorAddress: opA, Power: 0}}, trustLevelRatio).Execute(cross))
283}
284
285func TestNewValidatorProposalRequest_RejectsDuplicateOp(t *testing.T) {
286	// Each operator may appear at most once per proposal; any shape
287	// that mentions the same op twice must panic at create-time.
288	resetCache()
289	resetLimits()
290	op := testutils.TestAddress("op-A")
291	seedCache(t, []struct {
292		op          address
293		pubKey      string
294		keepRunning bool
295	}{{op: op, pubKey: pubKeyA, keepRunning: true}})
296
297	cases := [][]ValoperChange{
298		{{OperatorAddress: op, Power: 0}, {OperatorAddress: op, Power: 7}}, // remove + re-add
299		{{OperatorAddress: op, Power: 7}, {OperatorAddress: op, Power: 8}}, // double add
300		{{OperatorAddress: op, Power: 0}, {OperatorAddress: op, Power: 0}}, // double remove
301	}
302	for _, changes := range cases {
303		uassert.PanicsContains(t, "duplicate operator in proposal", func() {
304			_ = NewValidatorProposalRequest(changes, "dup", "")
305		})
306	}
307}
308
309func TestNewValidatorProposalRequest_RejectsPowerUpdatePairForOptedOutOp(t *testing.T) {
310	// KeepRunning=false is binding: no proposal shape can keep an
311	// opted-out operator in the active set. The dedupe rejection
312	// fires before the KR check is even reached.
313	resetCache()
314	resetLimits()
315	op := testutils.TestAddress("op-A")
316	seedCache(t, []struct {
317		op          address
318		pubKey      string
319		keepRunning bool
320	}{{op: op, pubKey: pubKeyA, keepRunning: false}})
321
322	uassert.PanicsContains(t, "duplicate operator in proposal", func() {
323		_ = NewValidatorProposalRequest(
324			[]ValoperChange{
325				{OperatorAddress: op, Power: 0},
326				{OperatorAddress: op, Power: 7},
327			},
328			"bypass attempt", "",
329		)
330	})
331}
332
333func TestNewValidatorProposalRequest_UpsertExistingValidator(t *testing.T) {
334	// Single-entry {op, newPower} against an op already in the
335	// effective valset must upsert: the existing entry's power is
336	// overwritten, no remove/re-add ceremony required.
337	resetValset(t)
338	resetCache()
339	resetLimits()
340
341	// Seed valset with two validators; we upsert opA's power.
342	testing.SetSysParamStrings(module, submodule, currKey, []string{
343		pubKeyA + ":1",
344		pubKeyB + ":1",
345	})
346
347	opA := testutils.TestAddress("op-A")
348	opB := testutils.TestAddress("op-B")
349	seedCache(t, []struct {
350		op          address
351		pubKey      string
352		keepRunning bool
353	}{
354		{op: opA, pubKey: pubKeyA, keepRunning: true},
355		{op: opB, pubKey: pubKeyB, keepRunning: true},
356	})
357
358	changes := []ValoperChange{{OperatorAddress: opA, Power: 9}}
359	urequire.NoError(t, newValoperChangeExecutor(changes, trustLevelRatio).Execute(cross))
360
361	effective := sysparams.GetValsetEffective()
362	powerOf := map[string]uint64{}
363	for _, v := range effective {
364		powerOf[v.PubKey] = v.VotingPower
365	}
366	uassert.Equal(t, uint64(9), powerOf[pubKeyA], "opA power upserted from 1 to 9")
367	uassert.Equal(t, uint64(1), powerOf[pubKeyB], "opB unchanged")
368}
369
370func TestNewValidatorProposalRequest_ExecutorRejectsRaceFlippedKeepRunning(t *testing.T) {
371	// KeepRunning=true at proposal-create time; operator flips to
372	// false BEFORE the executor runs. Race-safety check rejects.
373	resetValset(t)
374	resetCache()
375	resetLimits()
376
377	op := testutils.TestAddress("op-A")
378	seedCache(t, []struct {
379		op          address
380		pubKey      string
381		keepRunning bool
382	}{{op: op, pubKey: pubKeyA, keepRunning: true}})
383
384	changes := []ValoperChange{{OperatorAddress: op, Power: 5}}
385	exec := newValoperChangeExecutor(changes, trustLevelRatio)
386
387	// Operator flips KeepRunning=false BEFORE the executor runs.
388	valoperCache.Set(op.String(), cacheEntry{
389		SigningPubKey:  pubKeyA,
390		SigningAddress: mustAddr(t, pubKeyA),
391		KeepRunning:    false,
392	})
393
394	uassert.AbortsContains(t, "KeepRunning=false at execution", func() {
395		_ = exec.Execute(cross)
396	})
397}
398
399func TestNewValidatorProposalRequest_NaturalRotationFlow_NoGhost(t *testing.T) {
400	// RotateValoperSigningKey publishes valset:proposed before any
401	// subsequent executor reads, so baseline always reflects the
402	// post-rotation state. A later power-update upserts at NEW only;
403	// no OLD ghost.
404	resetValset(t)
405	resetCache()
406	resetLimits()
407
408	testing.SetSysParamStrings(module, submodule, currKey, []string{
409		pubKeyA + ":1",
410		pubKeyB + ":5",
411	})
412	opA := testutils.TestAddress("op-A")
413	opB := testutils.TestAddress("op-B")
414	seedCache(t, []struct {
415		op          address
416		pubKey      string
417		keepRunning bool
418	}{
419		{op: opA, pubKey: pubKeyA, keepRunning: true},
420		{op: opB, pubKey: pubKeyB, keepRunning: true},
421	})
422
423	testing.SetRealm(testing.NewCodeRealm(valopersRealmPath))
424	RotateValoperSigningKey(cross, opA, pubKeyA, pubKeyC)
425	NotifyValoperChanged(cross, opA, pubKeyC, mustAddr(t, pubKeyC), true)
426
427	testing.SetRealm(testing.NewCodeRealm("gno.land/r/gov/dao/v3/impl"))
428	urequire.NoError(t, newValoperChangeExecutor(
429		[]ValoperChange{{OperatorAddress: opA, Power: 2}},
430		trustLevelRatio,
431	).Execute(cross))
432
433	effective := sysparams.GetValsetEffective()
434	powerOf := map[string]uint64{}
435	for _, v := range effective {
436		powerOf[v.PubKey] = v.VotingPower
437	}
438	uassert.Equal(t, uint64(2), powerOf[pubKeyC], "opA published at NEW power=2")
439	uassert.Equal(t, uint64(5), powerOf[pubKeyB], "opB unchanged")
440	_, ghost := powerOf[pubKeyA]
441	uassert.False(t, ghost, "OLD signing key (pubKeyA) must not linger in valset")
442	urequire.Equal(t, 2, len(effective), "exactly two entries — opA(NEW), opB")
443}
444
445func TestNewValidatorProposalRequest_PhantomBaselineDocumentsUnreachableState(t *testing.T) {
446	// Pin executor behavior on a phantom state (cache=NEW,
447	// valset:current=OLD, dirty=false) — unreachable via natural
448	// flow because RotateValoperSigningKey publishes proposed
449	// before NotifyValoperChanged updates the cache. If a future
450	// code path ever updates the cache without going through
451	// Rotate, the asymmetry would be a real bug; this test pins
452	// the current behavior so the divergence surfaces.
453	resetValset(t)
454	resetCache()
455	resetLimits()
456
457	testing.SetSysParamStrings(module, submodule, currKey, []string{
458		pubKeyA + ":1",
459		pubKeyB + ":5",
460	})
461
462	opA := testutils.TestAddress("op-A")
463	opB := testutils.TestAddress("op-B")
464	seedCache(t, []struct {
465		op          address
466		pubKey      string
467		keepRunning bool
468	}{
469		{op: opA, pubKey: pubKeyC, keepRunning: true},
470		{op: opB, pubKey: pubKeyB, keepRunning: true},
471	})
472
473	urequire.NoError(t, newValoperChangeExecutor(
474		[]ValoperChange{{OperatorAddress: opA, Power: 2}},
475		trustLevelRatio,
476	).Execute(cross))
477
478	effective := sysparams.GetValsetEffective()
479	powerOf := map[string]uint64{}
480	for _, v := range effective {
481		powerOf[v.PubKey] = v.VotingPower
482	}
483	uassert.Equal(t, uint64(1), powerOf[pubKeyA], "phantom OLD lingers from baseline")
484	uassert.Equal(t, uint64(2), powerOf[pubKeyC], "executor upserts at NEW")
485	uassert.Equal(t, uint64(5), powerOf[pubKeyB], "opB unchanged")
486	urequire.Equal(t, 3, len(effective),
487		"three entries — phantom-state ghost; this state is unreachable via natural flow")
488}
489
490func TestNewValidatorProposalRequest_SameBlockExecuteThenRotate(t *testing.T) {
491	// Same-block ordering: proposal-execute writes proposed
492	// (dirty=true); a subsequent rotation reads proposed-when-dirty
493	// and accumulates the prior power change rather than clobbering
494	// back to current.
495	resetValset(t)
496	resetCache()
497	resetLimits()
498
499	testing.SetSysParamStrings(module, submodule, currKey, []string{
500		pubKeyA + ":1",
501		pubKeyB + ":5",
502	})
503
504	opA := testutils.TestAddress("op-A")
505	opB := testutils.TestAddress("op-B")
506	seedCache(t, []struct {
507		op          address
508		pubKey      string
509		keepRunning bool
510	}{
511		{op: opA, pubKey: pubKeyA, keepRunning: true},
512		{op: opB, pubKey: pubKeyB, keepRunning: true},
513	})
514
515	urequire.NoError(t, newValoperChangeExecutor(
516		[]ValoperChange{{OperatorAddress: opA, Power: 3}},
517		trustLevelRatio,
518	).Execute(cross))
519
520	testing.SetRealm(testing.NewCodeRealm(valopersRealmPath))
521	RotateValoperSigningKey(cross, opA, pubKeyA, pubKeyC)
522	NotifyValoperChanged(cross, opA, pubKeyC, mustAddr(t, pubKeyC), true)
523
524	effective := sysparams.GetValsetEffective()
525	powerOf := map[string]uint64{}
526	for _, v := range effective {
527		powerOf[v.PubKey] = v.VotingPower
528	}
529	uassert.Equal(t, uint64(3), powerOf[pubKeyC], "rotation accumulates with prior upsert; final power=3")
530	uassert.Equal(t, uint64(5), powerOf[pubKeyB], "opB unchanged")
531	_, gotOld := powerOf[pubKeyA]
532	uassert.False(t, gotOld, "OLD signing key (pubKeyA) must not appear after rotation")
533	urequire.Equal(t, 2, len(effective), "exactly two entries — opA(NEW) and opB")
534}
535
536// TestNewValidatorProposalRequest_ExecutorVanishedCacheEntryPanics pins
537// the "operator vanished from valoperCache between propose and execute"
538// branch. No production path deletes from valoperCache today (only Set
539// is called by NotifyValoperChanged), but bptree.BPTree exposes Remove
540// so the underlying data structure does support deletion. This test
541// simulates that hypothetical state directly via the package-private
542// cache var to confirm the executor panics with the documented message
543// rather than silently mis-publishing an empty/wrong valset.
544//
545// If a future commit ever introduces a public cache-delete path, this
546// test still passes — it's a contract-pinning test for the panic itself.
547// If the team decides the branch is unreachable enough to drop, this
548// test is the first thing to break.
549func TestNewValidatorProposalRequest_ExecutorVanishedCacheEntryPanics(t *testing.T) {
550	resetValset(t)
551	resetCache()
552	resetLimits()
553
554	op := testutils.TestAddress("op-A")
555	seedCache(t, []struct {
556		op          address
557		pubKey      string
558		keepRunning bool
559	}{{op: op, pubKey: pubKeyA, keepRunning: true}})
560
561	exec := newValoperChangeExecutor(
562		[]ValoperChange{{OperatorAddress: op, Power: 5}},
563		trustLevelRatio,
564	)
565
566	// Simulate the unreachable-today state: cache entry deleted between
567	// proposal-create and proposal-execute. Direct package-private mutation
568	// (NOT a public API) — production code has no path here today.
569	_, removed := valoperCache.Remove(op.String())
570	urequire.True(t, removed, "fixture: cache entry must have been present before Remove")
571
572	uassert.AbortsContains(t, "operator vanished from valoperCache between propose and execute", func() {
573		_ = exec.Execute(cross)
574	})
575}
576
577// contains is a tiny strings.Contains shim so tests don't import a
578// new package.
579func contains(s, substr string) bool {
580	for i := 0; i+len(substr) <= len(s); i++ {
581		if s[i:i+len(substr)] == substr {
582			return true
583		}
584	}
585	return false
586}