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