upkeep.gno
14.60 Kb · 390 lines
1// Package upkeep pays people to run the ecosystem's permissionless
2// valves. The portfolio's realms deliberately expose maintenance
3// entrypoints that anyone may call — subscriptions.Expire frees a lapsed
4// subscription slot, timelock_guardian.Execute fires a matured timelocked
5// action — because no slot's liveness may depend on an interested party
6// showing up. This realm adds the missing economics: funders finance a
7// reward pot, and whoever triggers a valve THROUGH this realm is credited
8// a bounded reward, claimable by pull.
9//
10// THE REALM-TO-REALM BOUNDARY, PRECISELY:
11//
12// which calls which : upkeep -> subscriptions.Expire(cross, id)
13// upkeep -> timelock_guardian.Execute(cross, id)
14// why it is necessary : the reward must be conditioned on the valve
15// actually firing. Only making the call from
16// inside this realm ties "the valve fired" and
17// "the reward is credited" into one atomic
18// transaction; observing from outside cannot.
19// caller identity : downstream sees cur.Previous() = THIS realm,
20// not the poking EOA. Both valves are
21// permissionless BY DESIGN and use the caller
22// identity for nothing, so the intermediary
23// changes no authorization outcome. This realm
24// is not a deputy for any downstream authority
25// — it holds none to confuse.
26// authorization boundary: upstream, anyone may poke (the reward is the
27// only thing at stake and the pot is the only
28// source). Downstream, each valve enforces its
29// own STATE conditions (grace elapsed, delay
30// matured) exactly as it would for a direct
31// caller.
32// ordering : downstream call FIRST, reward accounting
33// AFTER. A downstream abort therefore reverts
34// the whole transaction before any pot or
35// ledger mutation exists.
36// failure behavior : any downstream panic (not expirable, too
37// early, already executed, unknown id...)
38// aborts this transaction. No partial state,
39// no reward, by VM atomicity — not by cleanup
40// code.
41// atomicity assumptions : a Gno transaction is all-or-nothing across
42// realm boundaries; there is no catch/recover
43// anywhere on this path (and none may be added
44// — recovery would break exactly this
45// guarantee).
46// value movement : none crosses the boundary. Both valves move
47// no coins; the poke transaction must carry no
48// coins (subscriptions' assertNoSend reads the
49// ORIGIN envelope unconditionally — measured,
50// not assumed). Rewards move only inside this
51// realm's ledger, funded by explicit Fund
52// transactions.
53// downstream rejection : reward denied automatically — the abort is
54// the denial.
55// downstream trust : neither valve trusts nor validates the
56// caller; both validate state. This realm
57// symmetrically does not trust the downstream
58// REPLY beyond "it did not abort".
59// replay : enforced downstream. A second Expire on the
60// same subscription aborts ("subscription is
61// expired"); a second Execute aborts ("action
62// already executed"). A poker cannot be paid
63// twice for one valve event.
64// adversarial callers : an EOA or realm poking with bogus ids,
65// premature targets, or replays hits a
66// downstream abort and pays its own gas. The
67// remaining economic edge — manufacturing
68// expirable state to farm rewards — differs
69// per task. sub_expire: every farmed
70// subscription permanently locks a sub-record
71// storage deposit of roughly 30,000ugnot at
72// the observed 100ugnot/byte rate (Expire
73// flips status; it does not free the record),
74// which exceeds MaxReward before price and
75// gas — loss-making at any legal setting.
76// timelock_execute: a farmed Action record is
77// small and its deposit can sit below the
78// cap, so farming resistance there rests on
79// the admin keeping the setting below the
80// measured cycle cost. Funders trust the cap
81// AND the admin's reward policy, not the cap
82// alone. The pot is a donation either way —
83// farming can drain it, never third-party
84// balances.
85//
86// Rewards default to 0 per task; the admin sets them within the
87// compile-time MaxReward. The pot only moves down via successful pokes
88// and only up via Fund. Conservation: Held == pot + UsersTotal
89// (+ out-of-band surplus, recoverable above that reserve by SweepDenom).
90// The ledger's fee cap is 0 — no fee exists anywhere in this realm.
91package upkeep
92
93import (
94 "chain"
95 "chain/runtime/unsafe"
96 "strconv"
97
98 "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/coinio"
99 "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/feeledger"
100
101 subs "gno.land/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/subscriptions"
102 guardian "gno.land/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/timelock_guardian"
103)
104
105// Denom is the only asset this realm accepts.
106const Denom = "ugnot"
107
108// Task identifiers — the two supported valves.
109const (
110 TaskSubExpire = "sub_expire"
111 TaskTimelockExecute = "timelock_execute"
112)
113
114// MaxReward is the compile-time ceiling on the per-poke reward:
115// 20,000ugnot (0.02 GNOT). It bounds what any single poke can extract
116// from the pot. For sub_expire it also defeats farming outright: one
117// manufactured expirable subscription permanently locks a sub-record
118// deposit of ~30,000ugnot at the observed 100ugnot/byte rate — already
119// above the cap before price and gas. For timelock_execute the farmed
120// Action record is smaller and its deposit can sit below the cap, so
121// there the admin's setting, kept below the measured farm-cycle cost,
122// is what deters farming. Funders trust the cap and the admin's
123// policy together; the pot they fund is an explicit donation either
124// way.
125const MaxReward = int64(20000)
126
127var (
128 admin address // may set rewards, stage a successor
129 pendingAdmin address // staged by TransferAdmin, completes via AcceptAdmin
130 sweeper address // may sweep out-of-band surplus
131
132 self address // this realm's address, captured at deploy
133 pot int64 // funded, not-yet-awarded ugnot
134
135 rewards = map[string]int64{
136 TaskSubExpire: 0,
137 TaskTimelockExecute: 0,
138 }
139 ledger = feeledger.MustNew(0)
140
141 // lifetime counters, rendered for operators
142 pokes int64
143 funded int64
144)
145
146func init() {
147 admin = unsafe.OriginCaller()
148 sweeper = admin
149 self = unsafe.CurrentRealm().Address()
150}
151
152// --- funding ---
153
154// Fund adds the attached coins to the reward pot. Anyone may fund;
155// funding is a donation to ecosystem maintenance and is not refundable.
156func Fund(cur realm) {
157 funder, amount := coinio.Receive(0, cur, Denom)
158 newPot, ok := checkedAdd(pot, amount)
159 if !ok {
160 panic("pot would overflow")
161 }
162 pot = newPot
163 funded += amount
164 chain.Emit("Funded", "from", funder.String(), "amount", itoa(amount),
165 "pot", itoa(pot))
166}
167
168// --- the pokes: the realm-to-realm calls ---
169
170// PokeExpire triggers subscriptions.Expire(subID) through this realm and
171// credits the caller the sub_expire reward. The downstream realm decides
172// whether the subscription is expirable; its abort is the authorization.
173// The transaction must attach no coins (the downstream realm checks the
174// origin envelope).
175func PokeExpire(cur realm, subID int64) {
176 assertNoSend()
177 caller := cur.Previous().Address()
178 reward := requireReward(TaskSubExpire)
179
180 // Downstream first: an abort here reverts everything below.
181 subs.Expire(cross(cur), subID)
182
183 award(caller, reward, TaskSubExpire, itoa(subID))
184}
185
186// PokeExecute triggers timelock_guardian.Execute(actionID) through this
187// realm and credits the caller the timelock_execute reward. The guardian
188// decides whether the action is executable; its abort is the
189// authorization.
190func PokeExecute(cur realm, actionID string) {
191 assertNoSend()
192 caller := cur.Previous().Address()
193 reward := requireReward(TaskTimelockExecute)
194
195 guardian.Execute(cross(cur), actionID)
196
197 award(caller, reward, TaskTimelockExecute, actionID)
198}
199
200// award moves reward from the pot to the caller's claimable balance.
201// Callers have already established that the pot covers it.
202func award(caller address, reward int64, task, target string) {
203 pot -= reward
204 ledger.MustDeposit(caller.String(), reward, 0)
205 pokes++
206 chain.Emit("Poked",
207 "task", task,
208 "target", target,
209 "caller", caller.String(),
210 "reward", itoa(reward),
211 "pot", itoa(pot),
212 )
213}
214
215// requireReward returns the configured reward for a task, refusing when
216// the task is unknown, unrewarded, or the pot cannot cover it. Refusal
217// on an empty pot is deliberate: both valves remain directly callable on
218// their own realms, so an unrewarded detour through this one is only a
219// gas trap for the caller.
220func requireReward(task string) int64 {
221 r, ok := rewards[task]
222 if !ok {
223 panic("unknown task: " + task)
224 }
225 if r <= 0 {
226 panic("no reward configured for " + task)
227 }
228 if pot < r {
229 panic("reward pot cannot cover " + task + ": pot " + itoa(pot) +
230 ", reward " + itoa(r))
231 }
232 return r
233}
234
235// --- claims ---
236
237// Claim sends amount ugnot of the caller's earned balance back to the
238// caller.
239func Claim(cur realm, amount int64) {
240 assertNoSend()
241 caller := cur.Previous().Address()
242 if err := ledger.Withdraw(caller.String(), amount); err != nil {
243 panic(err)
244 }
245 coinio.Payout(0, cur, caller, Denom, amount)
246 chain.Emit("Claim", "to", caller.String(), "amount", itoa(amount))
247}
248
249// ClaimAll sends the caller's entire earned balance back to the caller.
250func ClaimAll(cur realm) {
251 assertNoSend()
252 caller := cur.Previous().Address()
253 amount, err := ledger.WithdrawAll(caller.String())
254 if err != nil {
255 panic(err)
256 }
257 if amount == 0 {
258 panic("nothing to claim")
259 }
260 coinio.Payout(0, cur, caller, Denom, amount)
261 chain.Emit("Claim", "to", caller.String(), "amount", itoa(amount))
262}
263
264// --- administration ---
265
266// SetReward configures the per-poke reward for a task, bounded by
267// MaxReward. Admin only. Zero disables the task.
268func SetReward(cur realm, task string, amount int64) {
269 assertNoSend()
270 assertAdmin(cur.Previous().Address())
271 if _, ok := rewards[task]; !ok {
272 panic("unknown task: " + task)
273 }
274 if amount < 0 || amount > MaxReward {
275 panic("reward must be in [0, " + itoa(MaxReward) + "]")
276 }
277 old := rewards[task]
278 rewards[task] = amount
279 chain.Emit("RewardChanged", "task", task, "old", itoa(old), "new", itoa(amount))
280}
281
282// SweepDenom recovers out-of-band coins to the sweeper. For the pot
283// denom the reserve is pot + earned balances — both structurally
284// unreachable. Sweeper only.
285func SweepDenom(cur realm, denom string) {
286 assertNoSend()
287 if cur.Previous().Address() != sweeper {
288 panic("sweeper only")
289 }
290 reserve := int64(0)
291 if denom == Denom {
292 reserve = pot + ledger.Liabilities()
293 }
294 swept := coinio.Sweep(0, cur, sweeper, denom, reserve)
295 chain.Emit("SurplusSwept", "to", sweeper.String(), "coins", itoa(swept)+denom)
296}
297
298// TransferAdmin stages a two-step admin handover.
299func TransferAdmin(cur realm, successor address) {
300 assertNoSend()
301 assertAdmin(cur.Previous().Address())
302 var zero address
303 if successor == zero {
304 panic("successor must not be the zero address")
305 }
306 pendingAdmin = successor
307 chain.Emit("AdminTransferStaged", "from", admin.String(), "to", successor.String())
308}
309
310// AcceptAdmin completes the handover; only the staged successor may.
311// The sweeper role moves with the admin.
312func AcceptAdmin(cur realm) {
313 assertNoSend()
314 caller := cur.Previous().Address()
315 if caller != pendingAdmin {
316 panic("only the staged successor may accept")
317 }
318 old := admin
319 admin = caller
320 sweeper = caller
321 var zero address
322 pendingAdmin = zero
323 chain.Emit("AdminTransferred", "from", old.String(), "to", admin.String())
324}
325
326// --- views ---
327
328func Admin() address { return admin }
329func PendingAdmin() address { return pendingAdmin }
330func Pot() int64 { return pot }
331func RewardFor(task string) int64 {
332 r, ok := rewards[task]
333 if !ok {
334 panic("unknown task: " + task)
335 }
336 return r
337}
338func BalanceOf(a address) int64 { return ledger.BalanceOf(a.String()) }
339func UsersTotal() int64 { return ledger.UsersTotal() }
340func Pokes() int64 { return pokes }
341func Funded() int64 { return funded }
342func Address() address { return self }
343func Held() int64 { return coinio.HeldAt(self, Denom) }
344
345// --- render ---
346
347func Render(path string) string {
348 if path != "" {
349 return "unknown page; try the realm root"
350 }
351 out := "# upkeep\n\n"
352 out += "Rewards for running the ecosystem's permissionless valves. " +
353 "Fund the pot; poke a valve through this realm; claim what you " +
354 "earn. A poke succeeds only when the downstream realm accepts " +
355 "the valve call in the same transaction.\n\n"
356 out += "- pot: " + itoa(pot) + Denom + "\n"
357 out += "- earned, unclaimed: " + itoa(ledger.UsersTotal()) + Denom + "\n"
358 out += "- lifetime pokes: " + itoa(pokes) + "\n"
359 out += "- lifetime funding: " + itoa(funded) + Denom + "\n\n"
360 out += "## rewards per poke\n\n"
361 out += "- `" + TaskSubExpire + "` (subscriptions.Expire): " +
362 itoa(rewards[TaskSubExpire]) + Denom + "\n"
363 out += "- `" + TaskTimelockExecute + "` (timelock_guardian.Execute): " +
364 itoa(rewards[TaskTimelockExecute]) + Denom + "\n"
365 out += "\n(cap per poke: " + itoa(MaxReward) + Denom + ", compile-time)\n"
366 return out
367}
368
369// --- internals ---
370
371func assertNoSend() {
372 if len(unsafe.OriginSend()) != 0 {
373 panic("this function does not accept coins")
374 }
375}
376
377func assertAdmin(caller address) {
378 if caller != admin {
379 panic("admin only")
380 }
381}
382
383func checkedAdd(a, b int64) (int64, bool) {
384 if b > 0 && a > int64(^uint64(0)>>1)-b {
385 return 0, false
386 }
387 return a + b, true
388}
389
390func itoa(n int64) string { return strconv.FormatInt(n, 10) }