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_test.gno

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