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}