1// Package revshare is a team revenue realm that IS a subscription2// provider. The live subscriptions realm supports realm providers but3// warns, in its own frozen header, that a realm provider "must expose4// 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 on6// top of it: pulled revenue is split among weighted members into7// pull-claimable balances.8//9// THE ECONOMIC COMPOSITION, PRECISELY:10//11// components : subscriptions (live realm — the revenue12// machine and the upstream custodian),13// feeledger (member balance accounting,14// fee cap 0), coinio (payouts, held-balance15// reads, reserve-protected sweep).16// value boundaries : B1 subscriber EOA -> subscriptions (plan17// price, origin envelope; governed by18// subscriptions' own H == U + F). B219// subscriptions -> revshare (banker send:20// ClaimAll pays the caller, and the caller21// is this realm). B3 revshare -> member EOA22// (coinio.Payout on Claim/ClaimAll).23// who owns which state : subscriptions owns plans, subs and the24// provider's claimable balance (revshare's25// RECEIVABLE); revshare owns the member26// table, weights, and the member balances in27// its own ledger. No state is shared; the28// only coupling is the crossing calls and29// the coins that move over B2.30// who controls funds : upstream, only a claim by this realm can31// move its receivable (provider-keyed32// ledger). Here, member balances move only33// to their owner (pull claims); the admin34// can NEVER touch earned balances — sweep35// reserves them, weight changes affect only36// FUTURE distributions.37// identity propagation : downstream sees cur.Previous() = THIS38// realm on every crossing call, so the plan39// provider and the claim beneficiary are the40// realm address by construction — no admin41// or member identity ever reaches the42// downstream realm.43// conservation : Held == UsersTotal + S (surplus above the44// Liabilities() reserve, recoverable only by45// SweepDenom). Distribution is exact: shares46// are floor(amount*w/W) via the47// overflow-free split (A/W)*w + ((A%W)*w)/W,48// and the remainder goes to the49// highest-weight member (ties: lowest50// address) — fee_split's deterministic dust51// policy, so no residual pool exists.52// Cross-boundary: lifetime Pulled equals the53// sum of all distributions, and the54// downstream receivable is NOT part of Held.55// ordering : Pull refuses BEFORE the downstream call56// (no members configured = refuse), claims57// downstream, MEASURES the arrival as a58// held-balance delta, then distributes59// exactly what arrived. State-after-call:60// the only local mutations happen after the61// boundary, on measured coins.62// downstream abort : "nothing to claim" (or any downstream63// panic) aborts the whole Pull — no local64// state exists yet to corrupt, by ordering65// AND by VM atomicity. There is no recover66// anywhere in this realm, and none may be67// added.68// replay : a second Pull finds a zero downstream69// balance and aborts there. Distribution70// credits are driven by the measured delta,71// so a replayed Pull cannot double-count72// even in principle.73// trust : revshare does not trust the downstream74// reply beyond "it did not abort" — it75// distributes the measured balance delta,76// not a reported amount. Downstream77// validates nothing about this caller; the78// provider ledger is keyed by address.79// terminal states : plans retire downstream (provider-only,80// exposed here admin-gated); members can be81// removed (earned balances survive removal82// and stay claimable); the realm itself has83// no terminal state — a team that walks away84// leaves only pull-claimable balances.85// adversarial callers : Pull is permissionless — it can only move86// the receivable into member balances at the87// fixed weights, so a stranger's Pull is a88// free favor. Plan creation/retirement is89// admin-gated (the downstream per-provider90// plan quota is a griefable resource).91// Members trust the admin for FUTURE weights92// only, never for earned balances. All93// entrypoints refuse coin-carrying94// transactions (the downstream assertNoSend95// reads the origin envelope; ours matches).96//97// One team per deploy: subscriptions keeps ONE claimable balance per98// provider address, so a multi-team router behind one realm address99// could not attribute revenue at the boundary. This is a measured100// constraint of the downstream API, not a choice.101package revshare102103import (104 "chain"105 "chain/runtime/unsafe"106 "strconv"107108 "gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/coinio"109 "gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/feeledger"110111 subs "gno.land/r/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/subscriptions"112)113114// Denom is the only asset this realm accounts.115const Denom = "ugnot"116117// MaxMembers bounds the distribution loop; MaxWeight bounds a single118// weight. Together they cap totalWeight at 200,000, which makes the119// remainder step of the split ((A%W)*w) provably overflow-free for120// any int64 coin amount — the conservation math cannot trap.121const (122 MaxMembers = 20123 MaxWeight = int64(10000)124)125126type member struct {127 addr address128 weight int64129}130131var (132 admin address // manages members, plans; stages a successor133 pendingAdmin address // staged by TransferAdmin, completes via AcceptAdmin134 sweeper address // may sweep out-of-band surplus135136 self address // this realm's address, captured at deploy137138 members []member // insertion-ordered; bounded by MaxMembers139 totalWeight int64140141 ledger = feeledger.MustNew(0)142143 planIDs []int64 // plans created through this realm (ops/Render)144 pulled int64 // lifetime revenue pulled over boundary B2145)146147func init() {148 admin = unsafe.OriginCaller()149 sweeper = admin150 self = unsafe.CurrentRealm().Address()151}152153// --- membership (admin-gated; future distributions only) ---154155// AddMember adds a weighted member. Weight changes never touch earned156// balances; they shape future pulls only.157func AddMember(cur realm, a address, weight int64) {158 assertNoSend()159 assertAdmin(cur.Previous().Address())160 var zero address161 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 += weight175 chain.Emit("MemberAdded", "addr", a.String(), "weight", itoa(weight))176}177178// 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].weight190 totalWeight += weight - old191 members[i].weight = weight192 chain.Emit("WeightSet", "addr", a.String(), "old", itoa(old), "new", itoa(weight))193}194195// RemoveMember removes a member from future distributions. The196// 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].weight205 members = append(members[:i], members[i+1:]...)206 chain.Emit("MemberRemoved", "addr", a.String())207}208209// --- the provider surface: crossing paths into subscriptions ---210211// CreatePlan creates a subscription plan THROUGH this realm, making212// the realm the plan's provider downstream. Admin only: the213// 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 id222}223224// 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}231232// Pull claims this realm's entire accrued provider balance from the233// subscriptions realm and distributes it to the members by weight.234// Permissionless: pulling can only move the receivable into member235// balances at the fixed weights, so anyone may crank it. Refuses236// 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 }242243 before := coinio.HeldAt(self, Denom)244 subs.ClaimAll(cross(cur))245 amount := coinio.HeldAt(self, Denom) - before246 if amount <= 0 {247 panic("downstream claim delivered nothing")248 }249250 distribute(amount)251 pulled += amount252 chain.Emit("Pulled", "amount", itoa(amount), "pulledTotal", itoa(pulled))253 return amount254}255256// distribute splits amount by member weight. Shares are computed as257// (A/W)*w + ((A%W)*w)/W — floor(A*w/W) without any multiplication258// that can overflow. The remainder (< number of members) goes to the259// highest-weight member, lowest address on ties: deterministic, and260// it leaves the realm with no undistributed pool at rest.261func distribute(amount int64) {262 q := amount / totalWeight263 r := amount % totalWeight264265 distributed := int64(0)266 for _, m := range members {267 share := q*m.weight + (r*m.weight)/totalWeight268 if share > 0 {269 ledger.MustDeposit(m.addr.String(), share, 0)270 distributed += share271 }272 }273 if dust := amount - distributed; dust > 0 {274 ledger.MustDeposit(dustRecipient().String(), dust, 0)275 }276}277278func 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 = m284 }285 }286 return best.addr287}288289// --- member claims ---290291// Claim sends amount ugnot of the caller's earned balance to the292// 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}302303// 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}317318// --- administration ---319320// SweepDenom recovers out-of-band coins to the sweeper. For the321// accounting denom the reserve is the full ledger liability — member322// 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}335336// TransferAdmin stages a two-step admin handover.337func TransferAdmin(cur realm, successor address) {338 assertNoSend()339 assertAdmin(cur.Previous().Address())340 var zero address341 if successor == zero {342 panic("successor must not be the zero address")343 }344 pendingAdmin = successor345 chain.Emit("AdminTransferStaged", "from", admin.String(), "to", successor.String())346}347348// 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 := admin357 admin = caller358 sweeper = caller359 var zero address360 pendingAdmin = zero361 chain.Emit("AdminTransferred", "from", old.String(), "to", admin.String())362}363364// --- views ---365366func 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].weight376}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) }389390// Receivable reads this realm's accrued, not-yet-pulled provider391// balance inside the subscriptions realm — the other side of value392// boundary B2. It is deliberately NOT part of Held or of the local393// conservation equation.394func Receivable() int64 { return subs.BalanceOf(self) }395396// --- render ---397398func 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 out416 }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 out425}426427// --- internals ---428429func indexOf(a address) int {430 for i, m := range members {431 if m.addr == a {432 return i433 }434 }435 return -1436}437438func assertNoSend() {439 if len(unsafe.OriginSend()) != 0 {440 panic("this function does not accept coins")441 }442}443444func assertAdmin(caller address) {445 if caller != admin {446 panic("admin only")447 }448}449450func itoa(n int64) string { return strconv.FormatInt(n, 10) }451AcceptAdmin(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string})
AddMember(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, a string, weight int64)
Address() string
Admin() string
BalanceOf(a string) int64
Claim(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, amount int64)
ClaimAll(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string})
CreatePlan(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, title string, description string, price int64, periodBlocks int64, maxFeeBps int64) int64
Held() int64
MemberCount() int64
MemberWeight(a string) int64
NumPlans() int64
PendingAdmin() string
PlanID(i int64) int64
Pull(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}) int64
Pulled() int64
Receivable() int64
RemoveMember(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, a string)
Render(path string) string
RetirePlan(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, planID int64)
SetWeight(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, a string, weight int64)
SweepDenom(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, denom string)
TotalWeight() int64
TransferAdmin(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, successor string)
UsersTotal() int64
Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.
vm/qrender output, sanitized (docs/render-security.md) and displayed in an empty-sandbox iframe — scripts, forms and popups cannot run. Links stay inert in-preview; right-click to open.