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.

revshare.gno

16.40 Kb · 450 lines
  1// Package revshare is a team revenue realm that IS a subscription
  2// provider. The live subscriptions realm supports realm providers but
  3// warns, in its own frozen header, that a realm provider "must expose
  4// its own crossing path to Claim, or what it earns is stranded."
  5// revshare is that crossing path, plus the one thing a team needs on
  6// top of it: pulled revenue is split among weighted members into
  7// pull-claimable balances.
  8//
  9// THE ECONOMIC COMPOSITION, PRECISELY:
 10//
 11//	components            : subscriptions (live realm — the revenue
 12//	                        machine and the upstream custodian),
 13//	                        feeledger (member balance accounting,
 14//	                        fee cap 0), coinio (payouts, held-balance
 15//	                        reads, reserve-protected sweep).
 16//	value boundaries      : B1 subscriber EOA -> subscriptions (plan
 17//	                        price, origin envelope; governed by
 18//	                        subscriptions' own H == U + F). B2
 19//	                        subscriptions -> revshare (banker send:
 20//	                        ClaimAll pays the caller, and the caller
 21//	                        is this realm). B3 revshare -> member EOA
 22//	                        (coinio.Payout on Claim/ClaimAll).
 23//	who owns which state  : subscriptions owns plans, subs and the
 24//	                        provider's claimable balance (revshare's
 25//	                        RECEIVABLE); revshare owns the member
 26//	                        table, weights, and the member balances in
 27//	                        its own ledger. No state is shared; the
 28//	                        only coupling is the crossing calls and
 29//	                        the coins that move over B2.
 30//	who controls funds    : upstream, only a claim by this realm can
 31//	                        move its receivable (provider-keyed
 32//	                        ledger). Here, member balances move only
 33//	                        to their owner (pull claims); the admin
 34//	                        can NEVER touch earned balances — sweep
 35//	                        reserves them, weight changes affect only
 36//	                        FUTURE distributions.
 37//	identity propagation  : downstream sees cur.Previous() = THIS
 38//	                        realm on every crossing call, so the plan
 39//	                        provider and the claim beneficiary are the
 40//	                        realm address by construction — no admin
 41//	                        or member identity ever reaches the
 42//	                        downstream realm.
 43//	conservation          : Held == UsersTotal + S (surplus above the
 44//	                        Liabilities() reserve, recoverable only by
 45//	                        SweepDenom). Distribution is exact: shares
 46//	                        are floor(amount*w/W) via the
 47//	                        overflow-free split (A/W)*w + ((A%W)*w)/W,
 48//	                        and the remainder goes to the
 49//	                        highest-weight member (ties: lowest
 50//	                        address) — fee_split's deterministic dust
 51//	                        policy, so no residual pool exists.
 52//	                        Cross-boundary: lifetime Pulled equals the
 53//	                        sum of all distributions, and the
 54//	                        downstream receivable is NOT part of Held.
 55//	ordering              : Pull refuses BEFORE the downstream call
 56//	                        (no members configured = refuse), claims
 57//	                        downstream, MEASURES the arrival as a
 58//	                        held-balance delta, then distributes
 59//	                        exactly what arrived. State-after-call:
 60//	                        the only local mutations happen after the
 61//	                        boundary, on measured coins.
 62//	downstream abort      : "nothing to claim" (or any downstream
 63//	                        panic) aborts the whole Pull — no local
 64//	                        state exists yet to corrupt, by ordering
 65//	                        AND by VM atomicity. There is no recover
 66//	                        anywhere in this realm, and none may be
 67//	                        added.
 68//	replay                : a second Pull finds a zero downstream
 69//	                        balance and aborts there. Distribution
 70//	                        credits are driven by the measured delta,
 71//	                        so a replayed Pull cannot double-count
 72//	                        even in principle.
 73//	trust                 : revshare does not trust the downstream
 74//	                        reply beyond "it did not abort" — it
 75//	                        distributes the measured balance delta,
 76//	                        not a reported amount. Downstream
 77//	                        validates nothing about this caller; the
 78//	                        provider ledger is keyed by address.
 79//	terminal states       : plans retire downstream (provider-only,
 80//	                        exposed here admin-gated); members can be
 81//	                        removed (earned balances survive removal
 82//	                        and stay claimable); the realm itself has
 83//	                        no terminal state — a team that walks away
 84//	                        leaves only pull-claimable balances.
 85//	adversarial callers   : Pull is permissionless — it can only move
 86//	                        the receivable into member balances at the
 87//	                        fixed weights, so a stranger's Pull is a
 88//	                        free favor. Plan creation/retirement is
 89//	                        admin-gated (the downstream per-provider
 90//	                        plan quota is a griefable resource).
 91//	                        Members trust the admin for FUTURE weights
 92//	                        only, never for earned balances. All
 93//	                        entrypoints refuse coin-carrying
 94//	                        transactions (the downstream assertNoSend
 95//	                        reads the origin envelope; ours matches).
 96//
 97// One team per deploy: subscriptions keeps ONE claimable balance per
 98// provider address, so a multi-team router behind one realm address
 99// could not attribute revenue at the boundary. This is a measured
100// constraint of the downstream API, not a choice.
101package revshare
102
103import (
104	"chain"
105	"chain/runtime/unsafe"
106	"strconv"
107
108	"gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/coinio"
109	"gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/feeledger"
110
111	subs "gno.land/r/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/subscriptions"
112)
113
114// Denom is the only asset this realm accounts.
115const Denom = "ugnot"
116
117// MaxMembers bounds the distribution loop; MaxWeight bounds a single
118// weight. Together they cap totalWeight at 200,000, which makes the
119// remainder step of the split ((A%W)*w) provably overflow-free for
120// any int64 coin amount — the conservation math cannot trap.
121const (
122	MaxMembers = 20
123	MaxWeight  = int64(10000)
124)
125
126type member struct {
127	addr   address
128	weight int64
129}
130
131var (
132	admin        address // manages members, plans; stages a successor
133	pendingAdmin address // staged by TransferAdmin, completes via AcceptAdmin
134	sweeper      address // may sweep out-of-band surplus
135
136	self address // this realm's address, captured at deploy
137
138	members     []member // insertion-ordered; bounded by MaxMembers
139	totalWeight int64
140
141	ledger = feeledger.MustNew(0)
142
143	planIDs []int64 // plans created through this realm (ops/Render)
144	pulled  int64   // lifetime revenue pulled over boundary B2
145)
146
147func init() {
148	admin = unsafe.OriginCaller()
149	sweeper = admin
150	self = unsafe.CurrentRealm().Address()
151}
152
153// --- membership (admin-gated; future distributions only) ---
154
155// AddMember adds a weighted member. Weight changes never touch earned
156// balances; they shape future pulls only.
157func AddMember(cur realm, a address, weight int64) {
158	assertNoSend()
159	assertAdmin(cur.Previous().Address())
160	var zero address
161	if a == zero {
162		panic("member must not be the zero address")
163	}
164	if weight < 1 || weight > MaxWeight {
165		panic("weight must be in [1, " + itoa(MaxWeight) + "]")
166	}
167	if len(members) >= MaxMembers {
168		panic("member table is full (" + itoa(int64(MaxMembers)) + ")")
169	}
170	if indexOf(a) >= 0 {
171		panic("already a member")
172	}
173	members = append(members, member{addr: a, weight: weight})
174	totalWeight += weight
175	chain.Emit("MemberAdded", "addr", a.String(), "weight", itoa(weight))
176}
177
178// SetWeight changes a member's weight for future distributions.
179func SetWeight(cur realm, a address, weight int64) {
180	assertNoSend()
181	assertAdmin(cur.Previous().Address())
182	if weight < 1 || weight > MaxWeight {
183		panic("weight must be in [1, " + itoa(MaxWeight) + "]")
184	}
185	i := indexOf(a)
186	if i < 0 {
187		panic("not a member")
188	}
189	old := members[i].weight
190	totalWeight += weight - old
191	members[i].weight = weight
192	chain.Emit("WeightSet", "addr", a.String(), "old", itoa(old), "new", itoa(weight))
193}
194
195// RemoveMember removes a member from future distributions. The
196// member's earned balance is untouched and stays claimable forever.
197func RemoveMember(cur realm, a address) {
198	assertNoSend()
199	assertAdmin(cur.Previous().Address())
200	i := indexOf(a)
201	if i < 0 {
202		panic("not a member")
203	}
204	totalWeight -= members[i].weight
205	members = append(members[:i], members[i+1:]...)
206	chain.Emit("MemberRemoved", "addr", a.String())
207}
208
209// --- the provider surface: crossing paths into subscriptions ---
210
211// CreatePlan creates a subscription plan THROUGH this realm, making
212// the realm the plan's provider downstream. Admin only: the
213// per-provider plan quota downstream is a griefable resource.
214func CreatePlan(cur realm, title, description string, price, periodBlocks, maxFeeBps int64) int64 {
215	assertNoSend()
216	assertAdmin(cur.Previous().Address())
217	id := subs.CreatePlan(cross(cur), title, description, price, periodBlocks, maxFeeBps)
218	planIDs = append(planIDs, id)
219	chain.Emit("PlanCreated", "planId", itoa(id), "price", itoa(price),
220		"periodBlocks", itoa(periodBlocks))
221	return id
222}
223
224// RetirePlan retires one of this realm's plans downstream. Admin only.
225func RetirePlan(cur realm, planID int64) {
226	assertNoSend()
227	assertAdmin(cur.Previous().Address())
228	subs.RetirePlan(cross(cur), planID)
229	chain.Emit("PlanRetired", "planId", itoa(planID))
230}
231
232// Pull claims this realm's entire accrued provider balance from the
233// subscriptions realm and distributes it to the members by weight.
234// Permissionless: pulling can only move the receivable into member
235// balances at the fixed weights, so anyone may crank it. Refuses
236// BEFORE the downstream call when no member could receive the funds.
237func Pull(cur realm) int64 {
238	assertNoSend()
239	if totalWeight == 0 {
240		panic("no members configured to receive revenue")
241	}
242
243	before := coinio.HeldAt(self, Denom)
244	subs.ClaimAll(cross(cur))
245	amount := coinio.HeldAt(self, Denom) - before
246	if amount <= 0 {
247		panic("downstream claim delivered nothing")
248	}
249
250	distribute(amount)
251	pulled += amount
252	chain.Emit("Pulled", "amount", itoa(amount), "pulledTotal", itoa(pulled))
253	return amount
254}
255
256// distribute splits amount by member weight. Shares are computed as
257// (A/W)*w + ((A%W)*w)/W — floor(A*w/W) without any multiplication
258// that can overflow. The remainder (< number of members) goes to the
259// highest-weight member, lowest address on ties: deterministic, and
260// it leaves the realm with no undistributed pool at rest.
261func distribute(amount int64) {
262	q := amount / totalWeight
263	r := amount % totalWeight
264
265	distributed := int64(0)
266	for _, m := range members {
267		share := q*m.weight + (r*m.weight)/totalWeight
268		if share > 0 {
269			ledger.MustDeposit(m.addr.String(), share, 0)
270			distributed += share
271		}
272	}
273	if dust := amount - distributed; dust > 0 {
274		ledger.MustDeposit(dustRecipient().String(), dust, 0)
275	}
276}
277
278func dustRecipient() address {
279	best := members[0]
280	for _, m := range members[1:] {
281		if m.weight > best.weight ||
282			(m.weight == best.weight && m.addr.String() < best.addr.String()) {
283			best = m
284		}
285	}
286	return best.addr
287}
288
289// --- member claims ---
290
291// Claim sends amount ugnot of the caller's earned balance to the
292// caller.
293func Claim(cur realm, amount int64) {
294	assertNoSend()
295	caller := cur.Previous().Address()
296	if err := ledger.Withdraw(caller.String(), amount); err != nil {
297		panic(err)
298	}
299	coinio.Payout(0, cur, caller, Denom, amount)
300	chain.Emit("Claim", "to", caller.String(), "amount", itoa(amount))
301}
302
303// ClaimAll sends the caller's entire earned balance to the caller.
304func ClaimAll(cur realm) {
305	assertNoSend()
306	caller := cur.Previous().Address()
307	amount, err := ledger.WithdrawAll(caller.String())
308	if err != nil {
309		panic(err)
310	}
311	if amount == 0 {
312		panic("nothing to claim")
313	}
314	coinio.Payout(0, cur, caller, Denom, amount)
315	chain.Emit("Claim", "to", caller.String(), "amount", itoa(amount))
316}
317
318// --- administration ---
319
320// SweepDenom recovers out-of-band coins to the sweeper. For the
321// accounting denom the reserve is the full ledger liability — member
322// balances are structurally unsweepable. Sweeper only.
323func SweepDenom(cur realm, denom string) {
324	assertNoSend()
325	if cur.Previous().Address() != sweeper {
326		panic("sweeper only")
327	}
328	reserve := int64(0)
329	if denom == Denom {
330		reserve = ledger.Liabilities()
331	}
332	swept := coinio.Sweep(0, cur, sweeper, denom, reserve)
333	chain.Emit("SurplusSwept", "to", sweeper.String(), "coins", itoa(swept)+denom)
334}
335
336// TransferAdmin stages a two-step admin handover.
337func TransferAdmin(cur realm, successor address) {
338	assertNoSend()
339	assertAdmin(cur.Previous().Address())
340	var zero address
341	if successor == zero {
342		panic("successor must not be the zero address")
343	}
344	pendingAdmin = successor
345	chain.Emit("AdminTransferStaged", "from", admin.String(), "to", successor.String())
346}
347
348// AcceptAdmin completes the handover; only the staged successor may.
349// The sweeper role moves with the admin.
350func AcceptAdmin(cur realm) {
351	assertNoSend()
352	caller := cur.Previous().Address()
353	if caller != pendingAdmin {
354		panic("only the staged successor may accept")
355	}
356	old := admin
357	admin = caller
358	sweeper = caller
359	var zero address
360	pendingAdmin = zero
361	chain.Emit("AdminTransferred", "from", old.String(), "to", admin.String())
362}
363
364// --- views ---
365
366func Admin() address        { return admin }
367func PendingAdmin() address { return pendingAdmin }
368func MemberCount() int64    { return int64(len(members)) }
369func TotalWeight() int64    { return totalWeight }
370func MemberWeight(a address) int64 {
371	i := indexOf(a)
372	if i < 0 {
373		panic("not a member")
374	}
375	return members[i].weight
376}
377func BalanceOf(a address) int64 { return ledger.BalanceOf(a.String()) }
378func UsersTotal() int64         { return ledger.UsersTotal() }
379func Pulled() int64             { return pulled }
380func NumPlans() int64           { return int64(len(planIDs)) }
381func PlanID(i int64) int64 {
382	if i < 0 || i >= int64(len(planIDs)) {
383		panic("plan index out of range")
384	}
385	return planIDs[i]
386}
387func Address() address { return self }
388func Held() int64      { return coinio.HeldAt(self, Denom) }
389
390// Receivable reads this realm's accrued, not-yet-pulled provider
391// balance inside the subscriptions realm — the other side of value
392// boundary B2. It is deliberately NOT part of Held or of the local
393// conservation equation.
394func Receivable() int64 { return subs.BalanceOf(self) }
395
396// --- render ---
397
398func Render(path string) string {
399	if path != "" {
400		return "unknown page; try the realm root"
401	}
402	out := "# revshare\n\n"
403	out += "A team revenue realm: it is the PROVIDER of its " +
404		"subscription plans, pulls accrued revenue across the realm " +
405		"boundary, and splits it among weighted members into " +
406		"pull-claimable balances.\n\n"
407	out += "- receivable (in subscriptions): " + itoa(Receivable()) + Denom + "\n"
408	out += "- held here: " + itoa(Held()) + Denom + "\n"
409	out += "- earned, unclaimed: " + itoa(ledger.UsersTotal()) + Denom + "\n"
410	out += "- lifetime pulled: " + itoa(pulled) + Denom + "\n"
411	out += "- plans created here: " + itoa(int64(len(planIDs))) + "\n\n"
412	out += "## members\n\n"
413	if len(members) == 0 {
414		out += "(none configured)\n"
415		return out
416	}
417	out += "| member | weight | unclaimed |\n|---|---|---|\n"
418	for _, m := range members {
419		out += "| " + m.addr.String() + " | " + itoa(m.weight) + " | " +
420			itoa(ledger.BalanceOf(m.addr.String())) + Denom + " |\n"
421	}
422	out += "\n(total weight " + itoa(totalWeight) + "; rounding dust " +
423		"goes to the highest-weight member)\n"
424	return out
425}
426
427// --- internals ---
428
429func indexOf(a address) int {
430	for i, m := range members {
431		if m.addr == a {
432			return i
433		}
434	}
435	return -1
436}
437
438func assertNoSend() {
439	if len(unsafe.OriginSend()) != 0 {
440		panic("this function does not accept coins")
441	}
442}
443
444func assertAdmin(caller address) {
445	if caller != admin {
446		panic("admin only")
447	}
448}
449
450func itoa(n int64) string { return strconv.FormatInt(n, 10) }