diff --git a/examples/gno.land/r/sys/validators/v3/autofreeze.gno b/examples/gno.land/r/sys/validators/v3/autofreeze.gno new file mode 100644 index 00000000000..545eea9d898 --- /dev/null +++ b/examples/gno.land/r/sys/validators/v3/autofreeze.gno @@ -0,0 +1,346 @@ +// autofreeze.gno — Phase B: automated validator freeze via off-chain monitors +// +// This is a draft. Phase A (freeze.gno) handles manual freeze/unfreeze by +// GovDAO and a GovDAO-managed admin list. Phase B adds an automated path: +// registered off-chain monitors can propose a freeze with evidence, and the +// chain enforces safety invariants on every auto-freeze attempt. +// +// Design: +// +// Monitors are registered/deregistered by GovDAO (RegisterMonitor / +// DeregisterMonitor). Any registered monitor can call AutoFreezeValidator +// with a reason and evidence string. +// +// Every AutoFreezeValidator call is guarded by four on-chain invariants: +// +// 1. Power floor: post-freeze live power must be ≥ 80% of total power. +// (Replaces the count-based 2/3 floor used in Phase A.) +// +// 2. Auto-frozen cap: at most 1/8 of total power may be auto-frozen at once. +// +// 3. Protected set: validators in protectedSet cannot be auto-frozen. +// (GovDAO manages the set.) +// +// 4. Auto-expiry: auto-frozen entries expire after autoFreezeExpiryBlocks. +// ExpireAutoFrozen() can be called by anyone to sweep expired entries +// and restore them automatically. +// +// The monitor is UNTRUSTED for safety. A compromised monitor's worst-case +// outcome is bounded and time-limited by invariants 1-4. +// +// Open questions (same as Phase A ADR): +// - Phase C: require N-of-M monitor attestation before auto-freeze fires. +// - Rate limiting per monitor (currently no per-monitor rate limit). +// - Whether auto-unfreeze on expiry is always correct, or should require +// a human confirmation step. +package validators + +import ( + "strconv" + "strings" + + "chain" + "chain/runtime" + runtimeunsafe "chain/runtime/unsafe" + + "gno.land/p/nt/avl/v0" + "gno.land/p/nt/ufmt/v0" + "gno.land/p/sys/validators" + sysparams "gno.land/r/sys/params" +) + +// autoFreezeExpiryBlocks is how many blocks an auto-frozen entry lives before +// it is eligible for auto-unfreeze via ExpireAutoFrozen. ~1 day at 1s/block. +const autoFreezeExpiryBlocks = int64(86400) + +// powerFloorPct is the minimum fraction of total power that must remain live +// after an auto-freeze (80%). Stored as a numerator over 100. +const powerFloorPct = 80 + +// autoFrozenCapPct is the maximum fraction of total power that may be +// auto-frozen at once (12.5% ≈ 1/8). Stored as numerator over 100. +const autoFrozenCapPct = 12 + +// monitors holds registered monitor addresses (addr.String() → struct{}). +// Only GovDAO can modify this set. +var monitors = avl.NewTree() + +// protectedSet holds validator addresses that monitors cannot auto-freeze +// (addr.String() → struct{}). GovDAO manages this set. +var protectedSet = avl.NewTree() + +// autoFrozenSet tracks auto-frozen entries separately from manually frozen ones +// so expiry logic only touches auto-freezes. +// key: addr.String() → autoFrozenEntry +var autoFrozenSet = avl.NewTree() + +type autoFrozenEntry struct { + Addr address + PubKey string + Power uint64 + Reason string + Evidence string + Monitor address + FrozenAt int64 + ExpiresAt int64 +} + +// ---- auto-freeze ------------------------------------------------------------ + +// AutoFreezeValidator is called by a registered monitor to freeze a validator +// based on observed misbehaviour. All four safety invariants are checked +// before the freeze is applied. +// +// On success the validator is removed from the effective valset and an +// AutoValidatorFrozen event is emitted. The entry expires automatically +// after autoFreezeExpiryBlocks blocks. +func AutoFreezeValidator(cur realm, addr address, reason, evidence string) { + assertMonitorCaller(cur) + + key := addr.String() + + if IsFrozen(addr) { + panic("validator is already manually frozen: " + key) + } + if _, exists := autoFrozenSet.Get(key); exists { + panic("validator is already auto-frozen: " + key) + } + if _, protected := protectedSet.Get(key); protected { + panic("validator is in the protected set and cannot be auto-frozen: " + key) + } + + effective := sysparams.GetValsetEffective() + var target *validators.Validator + for i := range effective { + if effective[i].Address == addr { + target = &effective[i] + break + } + } + if target == nil { + panic("validator not found in effective valset: " + key) + } + + // Compute total and live power (excluding already-frozen validators). + totalPower := uint64(0) + livePower := uint64(0) + for _, v := range effective { + totalPower += v.VotingPower + if !IsFrozen(v.Address) { + if _, af := autoFrozenSet.Get(v.Address.String()); !af { + livePower += v.VotingPower + } + } + } + + // Invariant 1: power floor. + postFreezeLive := livePower - target.VotingPower + if postFreezeLive*100 < totalPower*powerFloorPct { + panic(ufmt.Sprintf( + "auto-freeze would drop live power to %d/%d (%d%%), below the %d%% floor", + postFreezeLive, totalPower, postFreezeLive*100/totalPower, powerFloorPct, + )) + } + + // Invariant 2: auto-frozen cap. + autoFrozenPower := uint64(0) + autoFrozenSet.Iterate("", "", func(_ string, raw any) bool { + e := raw.(autoFrozenEntry) + autoFrozenPower += e.Power + return false + }) + if (autoFrozenPower+target.VotingPower)*100 > totalPower*autoFrozenCapPct { + panic(ufmt.Sprintf( + "auto-freeze would push auto-frozen power to %d/%d, exceeding the %d%% cap", + autoFrozenPower+target.VotingPower, totalPower, autoFrozenCapPct, + )) + } + + monitorAddr := runtimeunsafe.OriginCaller() + now := runtime.ChainHeight() + + autoFrozenSet.Set(key, autoFrozenEntry{ + Addr: addr, + PubKey: target.PubKey, + Power: target.VotingPower, + Reason: reason, + Evidence: evidence, + Monitor: monitorAddr, + FrozenAt: now, + ExpiresAt: now + autoFreezeExpiryBlocks, + }) + + publishValsetWithout(cur, addr) + + chain.Emit( + "AutoValidatorFrozen", + "addr", key, + "monitor", monitorAddr.String(), + "reason", reason, + "expiresAt", strconv.FormatInt(now+autoFreezeExpiryBlocks, 10), + ) +} + +// ExpireAutoFrozen sweeps all auto-frozen entries whose ExpiresAt ≤ current +// height and restores them to the effective valset. Callable by anyone. +// +// Returns the number of entries restored. +func ExpireAutoFrozen(cur realm) int { + now := runtime.ChainHeight() + var toExpire []autoFrozenEntry + + autoFrozenSet.Iterate("", "", func(_ string, raw any) bool { + e := raw.(autoFrozenEntry) + if now >= e.ExpiresAt { + toExpire = append(toExpire, e) + } + return false + }) + + if len(toExpire) == 0 { + return 0 + } + + effective := sysparams.GetValsetEffective() + set := make(map[address]validators.Validator, len(effective)+len(toExpire)) + for _, v := range effective { + set[v.Address] = v + } + + for _, e := range toExpire { + autoFrozenSet.Remove(e.Addr.String()) + set[e.Addr] = validators.Validator{ + Address: e.Addr, + PubKey: e.PubKey, + VotingPower: e.Power, + } + chain.Emit( + "AutoValidatorExpired", + "addr", e.Addr.String(), + "frozenAt", strconv.FormatInt(e.FrozenAt, 10), + "expiredAt", strconv.FormatInt(now, 10), + ) + } + + publishValsetFromMap(cur, set) + return len(toExpire) +} + +// ---- monitor management (GovDAO only) --------------------------------------- + +// RegisterMonitor adds addr to the set of trusted monitors. GovDAO only. +func RegisterMonitor(cur realm, addr address) { + assertGovDAOCaller(cur) + monitors.Set(addr.String(), struct{}{}) + chain.Emit("MonitorRegistered", "addr", addr.String()) +} + +// DeregisterMonitor removes addr from the monitor set. GovDAO only. +func DeregisterMonitor(cur realm, addr address) { + assertGovDAOCaller(cur) + if _, removed := monitors.Remove(addr.String()); !removed { + panic("address not in monitor set: " + addr.String()) + } + chain.Emit("MonitorDeregistered", "addr", addr.String()) +} + +// IsMonitor returns true if addr is a registered monitor. +func IsMonitor(addr address) bool { + _, ok := monitors.Get(addr.String()) + return ok +} + +// GetMonitors returns all registered monitor addresses. +func GetMonitors() []address { + out := make([]address, 0, monitors.Size()) + monitors.Iterate("", "", func(key string, _ any) bool { + out = append(out, address(key)) + return false + }) + return out +} + +// ---- protected set management (GovDAO only) --------------------------------- + +// AddProtected adds addr to the set of validators that monitors cannot +// auto-freeze. GovDAO only. +func AddProtected(cur realm, addr address) { + assertGovDAOCaller(cur) + protectedSet.Set(addr.String(), struct{}{}) + chain.Emit("ProtectedAdded", "addr", addr.String()) +} + +// RemoveProtected removes addr from the protected set. GovDAO only. +func RemoveProtected(cur realm, addr address) { + assertGovDAOCaller(cur) + if _, removed := protectedSet.Remove(addr.String()); !removed { + panic("address not in protected set: " + addr.String()) + } + chain.Emit("ProtectedRemoved", "addr", addr.String()) +} + +// IsProtected returns true if addr is in the protected set. +func IsProtected(addr address) bool { + _, ok := protectedSet.Get(addr.String()) + return ok +} + +// ---- read helpers ----------------------------------------------------------- + +// IsAutoFrozen returns true if addr is currently auto-frozen. +func IsAutoFrozen(addr address) bool { + _, ok := autoFrozenSet.Get(addr.String()) + return ok +} + +// GetAutoFrozenValidators returns the addresses of all auto-frozen validators. +func GetAutoFrozenValidators() []address { + out := make([]address, 0, autoFrozenSet.Size()) + autoFrozenSet.Iterate("", "", func(_ string, raw any) bool { + e := raw.(autoFrozenEntry) + out = append(out, e.Addr) + return false + }) + return out +} + +// ---- render extension ------------------------------------------------------- + +// renderAutoFreeze appends auto-freeze status to Render output. +func renderAutoFreeze() string { + var sb strings.Builder + + sb.WriteString("\n## Monitors\n\n") + if monitors.Size() == 0 { + sb.WriteString("_(none registered)_\n") + } else { + monitors.Iterate("", "", func(key string, _ any) bool { + sb.WriteString("- " + key + "\n") + return false + }) + } + + if autoFrozenSet.Size() > 0 { + sb.WriteString("\n## Auto-Frozen Validators\n\n") + sb.WriteString("| Address | Monitor | Reason | Frozen At | Expires At |\n") + sb.WriteString("|---------|---------|--------|-----------|------------|\n") + autoFrozenSet.Iterate("", "", func(_ string, raw any) bool { + e := raw.(autoFrozenEntry) + sb.WriteString(ufmt.Sprintf("| %s | %s | %s | %d | %d |\n", + e.Addr.String(), e.Monitor.String(), e.Reason, e.FrozenAt, e.ExpiresAt)) + return false + }) + } + + return sb.String() +} + +// ---- internal auth ---------------------------------------------------------- + +// assertMonitorCaller panics if the OriginCaller is not a registered monitor. +func assertMonitorCaller(_ realm) { + caller := runtimeunsafe.OriginCaller() + if _, ok := monitors.Get(caller.String()); !ok { + panic("caller is not a registered monitor: " + caller.String()) + } +} diff --git a/examples/gno.land/r/sys/validators/v3/autofreeze_test.gno b/examples/gno.land/r/sys/validators/v3/autofreeze_test.gno new file mode 100644 index 00000000000..4a23d975718 --- /dev/null +++ b/examples/gno.land/r/sys/validators/v3/autofreeze_test.gno @@ -0,0 +1,123 @@ +// autofreeze_test.gno — unit tests for Phase B auto-freeze state helpers. +// +// Auth-gated functions (AutoFreezeValidator, RegisterMonitor, etc.) require +// a registered monitor as OriginCaller or a GovDAO call context — both +// unavailable in the test runner. State is mutated directly to test the +// invariant logic and read helpers. +package validators + +import ( + "testing" + + "gno.land/p/nt/avl/v0" + "gno.land/p/nt/urequire/v0" +) + +func resetAutoFreezeState(t *testing.T) { + t.Helper() + monitors = avl.NewTree() + protectedSet = avl.NewTree() + autoFrozenSet = avl.NewTree() +} + +// ---- monitor set ------------------------------------------------------------ + +func TestMonitors_DefaultEmpty(t *testing.T) { + resetAutoFreezeState(t) + urequire.Equal(t, 0, len(GetMonitors())) + urequire.False(t, IsMonitor(address("g1monitor"))) +} + +func TestMonitors_ManualAddRemove(t *testing.T) { + resetAutoFreezeState(t) + + m := address("g1monitor") + urequire.False(t, IsMonitor(m)) + + monitors.Set(m.String(), struct{}{}) + urequire.True(t, IsMonitor(m)) + urequire.Equal(t, 1, len(GetMonitors())) + + monitors.Remove(m.String()) + urequire.False(t, IsMonitor(m)) +} + +// ---- protected set ---------------------------------------------------------- + +func TestProtected_DefaultEmpty(t *testing.T) { + resetAutoFreezeState(t) + urequire.False(t, IsProtected(address("g1val"))) +} + +func TestProtected_ManualAddRemove(t *testing.T) { + resetAutoFreezeState(t) + + v := address("g1val") + protectedSet.Set(v.String(), struct{}{}) + urequire.True(t, IsProtected(v)) + + protectedSet.Remove(v.String()) + urequire.False(t, IsProtected(v)) +} + +// ---- auto-frozen set -------------------------------------------------------- + +func TestIsAutoFrozen_Default(t *testing.T) { + resetAutoFreezeState(t) + urequire.False(t, IsAutoFrozen(address("g1val"))) + urequire.Equal(t, 0, len(GetAutoFrozenValidators())) +} + +func TestIsAutoFrozen_AfterManualInsert(t *testing.T) { + resetAutoFreezeState(t) + + addr := address("g1autoval") + autoFrozenSet.Set(addr.String(), autoFrozenEntry{ + Addr: addr, + PubKey: "pk", + Power: 10, + Reason: "downtime", + Evidence: "blocks 100-200 missed", + Monitor: address("g1monitor"), + FrozenAt: 50, + ExpiresAt: 50 + autoFreezeExpiryBlocks, + }) + + urequire.True(t, IsAutoFrozen(addr)) + vals := GetAutoFrozenValidators() + urequire.Equal(t, 1, len(vals)) + urequire.Equal(t, addr.String(), vals[0].String()) +} + +// ---- invariant checks (logic only, no crossing calls) ----------------------- + +func TestPowerFloor_Logic(t *testing.T) { + // Verify the floor arithmetic: post-freeze live power must be >= 80% of total. + totalPower := uint64(1000) + targetPower := uint64(250) // removing 25% + + // livePower before freeze = 900 (100 already auto-frozen elsewhere) + livePower := uint64(900) + postFreeze := livePower - targetPower // 650 + + // 650/1000 = 65% < 80% — should be rejected. + urequire.True(t, postFreeze*100 < totalPower*powerFloorPct) + + // With a smaller target (50), post = 850, 85% >= 80% — should pass. + smallTarget := uint64(50) + postFreeze2 := livePower - smallTarget // 850 + urequire.False(t, postFreeze2*100 < totalPower*powerFloorPct) +} + +func TestAutoFrozenCap_Logic(t *testing.T) { + // Cap is 12% of total. Verify the arithmetic. + totalPower := uint64(1000) + alreadyFrozen := uint64(100) // 10% already auto-frozen + newTarget := uint64(30) // adding 3% → 13% total > 12% cap + + urequire.True(t, (alreadyFrozen+newTarget)*100 > totalPower*autoFrozenCapPct) + + // With newTarget = 10: 11% <= 12% — should pass. + smallTarget := uint64(10) + urequire.False(t, (alreadyFrozen+smallTarget)*100 > totalPower*autoFrozenCapPct) +} diff --git a/examples/gno.land/r/sys/validators/v3/validators.gno b/examples/gno.land/r/sys/validators/v3/validators.gno index 0278b853619..4afaa503d62 100644 --- a/examples/gno.land/r/sys/validators/v3/validators.gno +++ b/examples/gno.land/r/sys/validators/v3/validators.gno @@ -66,5 +66,6 @@ func Render(string) string { } } sb.WriteString(renderFrozen()) + sb.WriteString(renderAutoFreeze()) return sb.String() }