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}