Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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) }