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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/coinio"
 99	"gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/feeledger"
100
101	subs "gno.land/r/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/subscriptions"
102	guardian "gno.land/r/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/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) }