keeper.gno
5.80 Kb · 147 lines
1package nslogic
2
3import (
4 "chain"
5 "strconv"
6 "time"
7
8 "gno.land/r/nym-mnsrehearsal001/nsdata/v1"
9)
10
11/*
12THE KEEPER: a hot key that moves the GNOT/USD rate and nothing else
13(Ely, 2026-10-16).
14
15WHY A ROLE. The rate is MANUAL (pricing.gno, SetUsdRate): it sits where it
16was last written, and a stale rate during a sharp move sells names at the
17wrong price. Keeping it current is a job for a bot, and a bot lives on a
18server. The admin key must not, so the bot gets a key of its own that can
19do exactly one thing.
20
21WHAT IT CAN DO. Call SetUsdRate inside the bounds every instant change
22obeys (timelock.gno): a higher rate — GNOT fell — up to four times at once,
23every ten minutes, so a crash is followed at once; a lower rate — the
24direction that sells names cheap — at most 25%, once an hour. The clocks
25are the SAME ones the admin's instant moves use, so the two keys together
26still cheapen names one step an hour. Never through the timelock: a
27matured proposal belongs to the admin, and the keeper's call refuses an
28out-of-bound value even while one is waiting. Every other function refuses
29it, because every other function asks for the admin; the admin keeps
30SetUsdRate as before.
31
32WHAT ITS THEFT COSTS. Names a quarter cheaper each hour, in public — each
33move emits KeeperAction — until the admin, recovery or the guardian calls
34ClearKeeper, which is instant; or names made too dear to buy, an outage.
35Bounded in size, in rate and in time, and it never touches a name or a
36coin directly.
37
38NAMED THROUGH THE TIMELOCK, CLEARED AT ONCE. SetKeeper waits out the vault's
39action delay ("SetKeeper <addr>" through nsdata.ProposeAction), like naming
40a granter: otherwise a stolen admin key installs a second key that survives
41its own rotation. Clearing is the safe direction and is open to all three
42governance roles. An admin rotation does not clear the keeper by itself;
43the incident runbook does (MAINNET.md §1b).
44
45NEVER THE ADMIN, NEVER RECOVERY. A keeper on the admin's address adds
46nothing, and one on recovery's puts the cold key's address on a server.
47Both are refused when it is named, and recovery is refused again at call
48time, in case a later rotation moved recovery onto the keeper's address.
49
50Stored in the vault's globals like the granter, so a logic swap keeps it,
51and listed in managedGlobal, so SetGlobal cannot write round the timelock.
52*/
53
54const keyKeeper = "keeper" // global: the address that may move the rate
55
56// Keeper is the address allowed to move the rate, or "" for none.
57func Keeper() address { return address(nsdata.GetGlobal(keyKeeper)) }
58
59// SetKeeper names the keeper. Admin only, and TIMELOCKED: propose exactly
60// "SetKeeper <addr>" with nsdata.ProposeAction, then call once it matures.
61// ClearKeeper removes the role.
62func SetKeeper(cur realm, a address) {
63 assertNoSend(cur)
64 assertIsAdmin(cur)
65 emitAdmin(cur, "SetKeeper", "addr", a.String())
66 if !a.IsValid() {
67 panic("nslogic: not an address; ClearKeeper removes the keeper")
68 }
69 if a == nsdata.GetAdmin() || a == nsdata.GetRecovery() {
70 panic("nslogic: the keeper is a key of its own, never the admin or the recovery key")
71 }
72 requireAction(cur, actionID("SetKeeper", a.String()))
73 setGlobal(cur, keyKeeper, a.String())
74}
75
76// ClearKeeper removes the keeper at once. Admin, recovery or guardian —
77// the guardian bot's answer to a KeeperAction it did not expect. Clearing
78// when there is no keeper does nothing and still succeeds, so a bot may
79// call it without reading first.
80func ClearKeeper(cur realm) {
81 assertNoSend(cur)
82 caller := cur.Previous().Address()
83 if !isGovernor(caller) {
84 panic("nslogic: only the admin, recovery or the guardian may clear the keeper")
85 }
86 chain.Emit("KeeperCleared", "keeper", Keeper().String(), "by", caller.String())
87 setGlobal(cur, keyKeeper, "")
88}
89
90// isGovernor is the vault's three roles, read fresh. An unset role matches
91// nobody.
92func isGovernor(a address) bool {
93 if a == "" {
94 return false
95 }
96 return a == nsdata.GetAdmin() || a == nsdata.GetRecovery() || a == nsdata.GetGuardian()
97}
98
99// assertIsKeeper lets the keeper through and nobody else, with the refusal
100// a stranger has always had from an admin call.
101func assertIsKeeper(caller address) {
102 k := Keeper()
103 if k == "" || caller != k {
104 panic("nslogic: restricted to project admin")
105 }
106 if k == nsdata.GetRecovery() {
107 panic("nslogic: the keeper's address is now the recovery key's; name a new keeper")
108 }
109}
110
111/*
112keeperSetRate is SetUsdRate for the keeper: the same range check, the
113bound and the shared hourly slot, and no door into the timelock. Every
114move emits KeeperAction, with the value it replaced.
115*/
116func keeperSetRate(cur realm, ugnotPerUsd int64) {
117 caller := cur.Previous().Address()
118 assertIsKeeper(caller)
119 old := RateUgnotPerUsd()
120 chain.Emit("KeeperAction", "fn", "SetUsdRate", "by", caller.String(),
121 "ugnotPerUsd", fmtInt(ugnotPerUsd), "was", fmtInt(old))
122 if ugnotPerUsd <= 0 || ugnotPerUsd > maxRate {
123 panic("nslogic: a rate is 1.." + strconv.FormatInt(maxRate, 10) + " ugnot per USD")
124 }
125 keeperStep(keyRate, old, ugnotPerUsd)
126 setGlobal(cur, keyRate, strconv.FormatInt(ugnotPerUsd, 10))
127}
128
129// keeperStep is authorizeTweak with the timelock door bricked up: an
130// instant step inside its direction's bound, after that direction's rest,
131// or a refusal. It writes the same clocks, so the admin's next instant
132// move in that direction waits the same time.
133func keeperStep(setting string, old, next int64) {
134 if next == old {
135 return
136 }
137 dearer := next > old // the rate: higher means names cost more GNOT
138 if !withinStep(old, next, dearer) {
139 panic("nslogic: for the keeper, " + stepRefusal(setting, old, next, dearer) +
140 "; a larger move is the admin's, through the vault timelock")
141 }
142 now := time.Now().Unix()
143 if !clockRested(setting, dearer, now) {
144 panic("nslogic: " + restRefusal(setting, dearer) + "; the keeper waits for the next step")
145 }
146 lastTweak.Set(clockKey(setting, dearer), now)
147}