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

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/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/coinio"
109	"gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/feeledger"
110
111	subs "gno.land/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/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) }