← Back to Realms

Realm

gno.land/r/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/fee_split/v2

Overview

Realm Path
gno.land/r/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/fee_split/v2
Exported Functions
17
State Entries
18
Source Files
3
Total Package Entries
45

Exported Functions

17 exported functions

State

18 state entries

Source Code

FILES
fee_split.gno
go
1package fee_split
2
3import (
4	"chain"
5	"chain/banker"
6	"chain/runtime/unsafe"
7	"sort"
8	"strconv"
9	"strings"
10)
11
12// Split holds a fee-splitting configuration with percentage-based shares
13// denominated in basis points (1 bp = 0.01%, 10000 bp = 100%).
14type Split struct {
15	Owner          address
16	PendingOwner   address // two-step handover, must AcceptOwnership
17	Recipients     []address
18	Shares         []int64 // basis points, must sum to 10000
19	Balances       map[address]int64
20	TotalDeposited int64
21	TotalClaimed   int64
22	Frozen         bool
23	Archived       bool
24}
25
26const (
27	MaxRecipients     = 20
28	MaxSplitsPerOwner = 10
29	// Quotas are PER-OWNER only (round-4 audit): a global cap is a shared
30	// resource 50 sybil accounts could fill forever — and seeding grief
31	// splits with balances to keyless recipients made the fill
32	// unrecoverable even by the sybils. Per-owner quotas mean an attacker
33	// consumes only their own budget; state growth is gas-priced.
34	// Render is bounded separately (MaxRenderSplits).
35	MaxRenderSplits = 100
36	// MaxFeeBps is an IMMUTABLE ceiling on the protocol fee (1%). The
37	// admin can set any fee from 0 up to this cap, never above it — the
38	// cap, not the current setting, is what users must trust.
39	MaxFeeBps = int64(100)
40
41	// Pre-parse input bounds (round-4 audit): caps were enforced only
42	// AFTER full parsing, so a 1MB recipient list burned ~11B gas before
43	// refusal. 20 bech32 addresses + separators fit well within these.
44	MaxRecipientListLen = 1024
45	MaxShareListLen     = 128
46
47	// Denomination handled by this realm. Deposits must be exactly one
48	// coin of this denom; claims pay out in it.
49	Denom = "ugnot"
50
51	// Largest single deposit for which share math (amount * share,
52	// share <= 10000) cannot overflow int64.
53	MaxDepositAmount = int64(9223372036854775807) / 10000
54)
55
56var (
57	splits      map[string]*Split
58	splitIDs    []string // insertion-ordered for deterministic Render
59	ownerSplits map[address]int
60	nextID      int
61
62	// Protocol fee: taken from each deposit BEFORE distribution, at the
63	// rate in force at deposit time (never retroactive — credited
64	// balances are never touched). Defaults to zero.
65	feeBps          int64
66	feeAdmin        address // deployer; can set the fee and claim accrued fees
67	pendingFeeAdmin address // two-step handover, must AcceptFeeAdmin
68	feesAccrued     int64
69	feesClaimed     int64
70)
71
72func init() {
73	splits = make(map[string]*Split)
74	splitIDs = []string{}
75	ownerSplits = make(map[address]int)
76	nextID = 1
77	// The package deployer becomes the fee admin: on-chain, AddPackage
78	// runs init with the message creator as origin caller (verified
79	// against the VM keeper — a MsgAddPackage's creator is never zero).
80	// If OriginCaller() were ever empty (e.g. the gno test VM, which has
81	// no MsgAddPackage), the fee feature degrades SAFELY to disabled:
82	// SetFee requires the runtime-derived caller to equal feeAdmin and
83	// that caller is never empty, so
84	// feeBps can never leave 0 and no fee ever accrues — zero funds at
85	// risk. Do not "harden" this into a panic; the soft-disable is the
86	// safe behavior. Fee starts at ZERO regardless.
87	feeAdmin = unsafe.OriginCaller()
88	feeBps = 0
89}
90
91// ---------- helpers ----------
92
93func formatPct(bp int64) string {
94	whole := strconv.FormatInt(bp/100, 10)
95	frac := strconv.FormatInt(bp%100, 10)
96	if len(frac) == 1 {
97		frac = "0" + frac
98	}
99	return whole + "." + frac + "%"
100}
101
102func mustGetSplit(id string) *Split {
103	s, ok := splits[id]
104	if !ok {
105		panic("split not found: " + id)
106	}
107	return s
108}
109
110func mustGetActive(id string) *Split {
111	s := mustGetSplit(id)
112	if s.Archived {
113		panic("split is archived: " + id)
114	}
115	return s
116}
117
118func boolStr(v bool) string {
119	if v {
120		return "yes"
121	}
122	return "no"
123}
124
125func parseBasisPoints(raw string) []int64 {
126	if len(raw) > MaxShareListLen {
127		panic("share list too long")
128	}
129	parts := strings.Split(raw, ",")
130	out := make([]int64, len(parts))
131	var total int64
132	for i, s := range parts {
133		v, err := strconv.Atoi(strings.TrimSpace(s))
134		if err != nil || v <= 0 || v > 10000 {
135			// the upper bound is a security invariant, not hygiene: unbounded
136			// shares let the int64 total wrap back to exactly 10000, minting
137			// unbacked balances paid from the shared pool (re-audit P1)
138			panic("invalid share value: " + strings.TrimSpace(s))
139		}
140		out[i] = int64(v)
141		total += int64(v)
142	}
143	if total != 10000 {
144		panic("shares must sum to 10000 basis points, got " + strconv.FormatInt(total, 10))
145	}
146	return out
147}
148
149func parseAddresses(raw string) []address {
150	if len(raw) > MaxRecipientListLen {
151		panic("recipient list too long")
152	}
153	parts := strings.Split(raw, ",")
154	out := make([]address, len(parts))
155	for i, r := range parts {
156		a := address(strings.TrimSpace(r))
157		if a == "" {
158			panic("empty recipient address at position " + strconv.Itoa(i))
159		}
160		if !a.IsValid() || string(a) != strings.ToLower(string(a)) {
161			// lowercase is required, not cosmetic (round-3 audit): bech32
162			// accepts ALL-UPPERCASE as valid, but the runtime always returns
163			// the lowercase canonical form — an uppercase-keyed balance
164			// could never be claimed and would block Archive forever, and
165			// upper/lower duplicates would bypass the duplicate check
166			panic("invalid recipient address: " + string(a))
167		}
168		// NOTE: IsValid is a format check only — a well-formed address with
169		// no key holder (e.g. another package's derived address) will
170		// accumulate a balance nobody can claim, which also blocks Archive
171		// forever. Owners must list addresses they know can call Claim.
172		out[i] = a
173	}
174	return out
175}
176
177func validateRecipients(recipients []address, shares []int64) {
178	if len(recipients) != len(shares) {
179		panic("recipients and shares must have the same length")
180	}
181	if len(recipients) == 0 {
182		panic("at least one recipient is required")
183	}
184	if len(recipients) > MaxRecipients {
185		panic("too many recipients (max " + strconv.Itoa(MaxRecipients) + ")")
186	}
187	seen := make(map[address]bool)
188	for _, r := range recipients {
189		if seen[r] {
190			panic("duplicate recipient: " + string(r))
191		}
192		seen[r] = true
193	}
194}
195
196// sortedBalanceAddrs returns the balance-map keys in deterministic order,
197// so panics and renders never depend on map iteration order.
198func sortedBalanceAddrs(s *Split) []string {
199	addrs := make([]string, 0, len(s.Balances))
200	for a := range s.Balances {
201		addrs = append(addrs, string(a))
202	}
203	sort.Strings(addrs)
204	return addrs
205}
206
207// rejectStraySend aborts when coins are attached to a call that does not
208// accept them — the abort reverts the transfer back to the sender instead
209// of stranding the coins on the realm address (re-audit P3). Direct bank
210// transfers to the realm address remain unrecoverable by design.
211func rejectStraySend(cur realm) {
212	if !cur.IsCurrent() {
213		// every call site passes the entrypoint's own first cur; this
214		// pins that discipline against future call-site drift (v2
215		// audit G3 hardening)
216		panic("realm capability is not current")
217	}
218	if cur.Previous().IsUserCall() && len(unsafe.OriginSend()) > 0 {
219		panic("this function does not accept coins; attach coins to Deposit only")
220	}
221}
222
223// ---------- write operations ----------
224
225// CreateSplit registers a new split. The caller becomes the owner.
226// Recipients and shares are comma-separated; shares are in basis points
227// summing to 10000.
228func CreateSplit(cur realm, recipientList, shareList string) string {
229	rejectStraySend(cur)
230	owner := cur.Previous().Address()
231	if ownerSplits[owner] >= MaxSplitsPerOwner {
232		panic("per-owner split limit reached")
233	}
234
235	recipients := parseAddresses(recipientList)
236	shares := parseBasisPoints(shareList)
237	validateRecipients(recipients, shares)
238
239	id := "split_" + strconv.Itoa(nextID)
240	nextID++
241
242	balances := make(map[address]int64)
243	for _, r := range recipients {
244		balances[r] = 0
245	}
246
247	splits[id] = &Split{
248		Owner:      owner,
249		Recipients: recipients,
250		Shares:     shares,
251		Balances:   balances,
252	}
253	splitIDs = append(splitIDs, id)
254	ownerSplits[owner]++
255	return id
256}
257
258// Deposit distributes the coins sent with the call across recipients
259// proportionally.
260//
261// DEPLOYMENT PRECONDITION (round-4 audit): on a network with
262// restricted/token-locked ugnot transfers, the bank gate is
263// SENDER-whitelist-based — a whitelisted user's Deposit succeeds but
264// Claim sends FROM this realm's (non-whitelisted) address and reverts.
265// Funds would flow in and not out until the restriction lifts. Deploy
266// only to networks with unrestricted ugnot, or have governance
267// whitelist this realm's address first.
268//
269// LIMITATION (round-3 audit, documented): only direct user calls can
270// deposit. A DAO/realm treasury has NO deposit path — a realm-routed
271// call is refused, and a bare banker send to this realm's address is
272// an unrecoverable donation. Realm treasuries must route deposits
273// through a user account. The deposit is the ACTUAL attached send — exactly one
274// coin of Denom — so balances are always backed by funds this realm
275// holds. Direct user calls only: a deposit routed through an
276// intermediary realm would deliver its coins to that realm, not here,
277// and must be rejected. Rounding dust goes to the highest-share
278// recipient (deterministic, not order-dependent).
279func Deposit(cur realm, splitID string) {
280	s := mustGetActive(splitID)
281	if s.Frozen {
282		panic("split is frozen")
283	}
284
285	// IsUserCall, not IsUser: MsgRun passes IsUser but its attached send
286	// goes caller->caller — the coins never reach this realm, and OriginSend
287	// could be re-read across k calls in one run script (re-audit P1). A
288	// direct MsgCall's send provably lands on the called package address.
289	if !cur.Previous().IsUserCall() {
290		panic("deposits must be sent by direct call, not through another realm or a run script")
291	}
292	sent := unsafe.OriginSend()
293	if len(sent) != 1 || sent[0].Denom != Denom {
294		panic("deposit must send exactly one coin of " + Denom)
295	}
296	amount := sent[0].Amount
297	if amount <= 0 {
298		panic("amount must be greater than zero")
299	}
300	if amount > MaxDepositAmount {
301		panic("deposit exceeds maximum supported amount")
302	}
303	if s.TotalDeposited > int64(9223372036854775807)-amount {
304		panic("deposit would overflow split accounting")
305	}
306
307	// Protocol fee comes off the top; everything below distributes the
308	// NET amount, so the per-split conservation invariant
309	// (sum(balances)+TotalClaimed == TotalDeposited) is untouched.
310	// amount <= MaxDepositAmount and feeBps <= 100, so the product is
311	// far below overflow.
312	fee := (amount * feeBps) / 10000
313	if fee > 0 {
314		if feesAccrued > int64(9223372036854775807)-fee {
315			panic("fee accrual would overflow")
316		}
317		feesAccrued += fee
318		amount -= fee
319	}
320	if amount == 0 {
321		panic("deposit too small: fully consumed by the protocol fee")
322	}
323
324	s.TotalDeposited += amount
325
326	// Find the highest-share recipient for dust assignment
327	dustIdx := 0
328	for i := 1; i < len(s.Shares); i++ {
329		if s.Shares[i] > s.Shares[dustIdx] {
330			dustIdx = i
331		}
332	}
333
334	var distributed int64
335	for i, r := range s.Recipients {
336		share := (amount * s.Shares[i]) / 10000
337		s.Balances[r] += share
338		distributed += share
339	}
340
341	// Assign dust to highest-share recipient
342	dust := amount - distributed
343	if dust > 0 {
344		s.Balances[s.Recipients[dustIdx]] += dust
345	}
346}
347
348// Claim withdraws the caller's accumulated balance and SENDS the coins
349// to the caller's address. Balance is zeroed before the transfer.
350// Claims remain possible on frozen splits, and by ex-recipients whose
351// accrued balance predates a share update.
352func Claim(cur realm, splitID string) int64 {
353	rejectStraySend(cur)
354	s := mustGetActive(splitID)
355	addr := cur.Previous().Address()
356
357	bal, exists := s.Balances[addr]
358	if !exists {
359		panic("not a recipient of this split")
360	}
361	if bal == 0 {
362		panic("nothing to claim")
363	}
364
365	s.Balances[addr] = 0
366	s.TotalClaimed += bal
367
368	b := banker.NewBanker(banker.BankerTypeRealmSend, cur)
369	b.SendCoins(cur.Address(), addr,
370		chain.Coins{{Denom: Denom, Amount: bal}})
371	return bal
372}
373
374// UpdateShares replaces recipients and shares. Owner only. Not if frozen.
375// Removed recipients keep any accrued balance and can still Claim it.
376func UpdateShares(cur realm, splitID, recipientList, shareList string) {
377	rejectStraySend(cur)
378	s := mustGetActive(splitID)
379	if cur.Previous().Address() != s.Owner {
380		panic("only the owner can update shares")
381	}
382	if s.Frozen {
383		panic("split is frozen")
384	}
385
386	recipients := parseAddresses(recipientList)
387	shares := parseBasisPoints(shareList)
388	validateRecipients(recipients, shares)
389
390	for _, r := range recipients {
391		if _, ok := s.Balances[r]; !ok {
392			s.Balances[r] = 0
393		}
394	}
395
396	s.Recipients = recipients
397	s.Shares = shares
398}
399
400// TransferOwnership stages a two-step ownership handover; nothing
401// moves until the nominee calls AcceptOwnership. Two-step for the same
402// reason as the fee admin, plus one more (v2 audit Y1): a one-step
403// transfer consumed the RECIPIENT's per-owner quota without consent,
404// so poisoned splits could burn a victim's slots unarchivably. Staging
405// consumes nothing of the nominee's. Pass "" to clear a pending
406// nomination.
407func TransferOwnership(cur realm, splitID string, newOwner address) {
408	rejectStraySend(cur)
409	s := mustGetActive(splitID)
410	if cur.Previous().Address() != s.Owner {
411		panic("only the owner can transfer ownership")
412	}
413	if newOwner == "" {
414		s.PendingOwner = ""
415		return
416	}
417	if !newOwner.IsValid() || string(newOwner) != strings.ToLower(string(newOwner)) {
418		// see parseAddresses: an uppercase owner could never match the
419		// runtime-derived caller again — the split would be owner-less
420		panic("invalid new owner address: " + string(newOwner))
421	}
422	if newOwner == s.Owner {
423		panic("new owner is already the owner")
424	}
425	s.PendingOwner = newOwner
426}
427
428// AcceptOwnership completes the handover; only the nominee can accept.
429// The nominee's quota is checked HERE, at consent time — the split
430// slot moves only with the acceptor's own signature (v2 audit Y1: a
431// recipient's quota is never consumed without consent). A nomination
432// on a split that is later archived is inert (mustGetActive).
433func AcceptOwnership(cur realm, splitID string) {
434	rejectStraySend(cur)
435	s := mustGetActive(splitID)
436	caller := cur.Previous().Address()
437	if s.PendingOwner == "" || caller != s.PendingOwner {
438		panic("caller is not the pending owner")
439	}
440	if ownerSplits[caller] >= MaxSplitsPerOwner {
441		panic("new owner is at the per-owner split limit")
442	}
443
444	ownerSplits[s.Owner]--
445	if ownerSplits[s.Owner] <= 0 {
446		delete(ownerSplits, s.Owner)
447	}
448	ownerSplits[caller]++
449	s.Owner = caller
450	s.PendingOwner = ""
451}
452
453// Freeze permanently locks shares and stops further deposits. One-way,
454// cannot be undone. Claims remain possible.
455func Freeze(cur realm, splitID string) {
456	rejectStraySend(cur)
457	s := mustGetActive(splitID)
458	if cur.Previous().Address() != s.Owner {
459		panic("only the owner can freeze")
460	}
461	s.Frozen = true
462}
463
464// Archive marks a fully-claimed split as archived. Only the owner can
465// archive, and only if EVERY balance — including balances held by
466// ex-recipients removed in a share update — is zero, since archiving
467// blocks all further claims. Cannot be undone.
468func Archive(cur realm, splitID string) {
469	rejectStraySend(cur)
470	s := mustGetActive(splitID)
471	if cur.Previous().Address() != s.Owner {
472		panic("only the owner can archive")
473	}
474
475	for _, a := range sortedBalanceAddrs(s) {
476		if s.Balances[address(a)] > 0 {
477			panic("cannot archive: outstanding balance for " + a)
478		}
479	}
480
481	s.Archived = true
482
483	// Free the owner's slot so they can create new splits
484	ownerSplits[s.Owner]--
485	if ownerSplits[s.Owner] <= 0 {
486		delete(ownerSplits, s.Owner)
487	}
488
489	// Remove from active ID list (keeps map entry for audit)
490	for i, id := range splitIDs {
491		if id == splitID {
492			splitIDs = append(splitIDs[:i], splitIDs[i+1:]...)
493			break
494		}
495	}
496}
497
498// ---------- protocol fee ----------
499
500// SetFee sets the protocol fee in basis points, admin only, hard-capped
501// at MaxFeeBps. Applies to FUTURE deposits only.
502func SetFee(cur realm, bps int64) {
503	rejectStraySend(cur)
504	if cur.Previous().Address() != feeAdmin {
505		panic("only the fee admin can set the fee")
506	}
507	if bps < 0 || bps > MaxFeeBps {
508		panic("fee must be between 0 and " + strconv.FormatInt(MaxFeeBps, 10) + " basis points")
509	}
510	feeBps = bps
511}
512
513// ClaimFees sends all accrued protocol fees to the fee admin.
514func ClaimFees(cur realm) int64 {
515	rejectStraySend(cur)
516	if cur.Previous().Address() != feeAdmin {
517		panic("only the fee admin can claim fees")
518	}
519	if feesAccrued == 0 {
520		panic("no fees accrued")
521	}
522	amount := feesAccrued
523	feesAccrued = 0
524	feesClaimed += amount
525
526	b := banker.NewBanker(banker.BankerTypeRealmSend, cur)
527	b.SendCoins(cur.Address(), feeAdmin,
528		chain.Coins{{Denom: Denom, Amount: amount}})
529	return amount
530}
531
532// NominateFeeAdmin begins a two-step admin handover; the nominee must
533// AcceptFeeAdmin. Pass "" to clear a pending nomination. Two-step
534// because the admin address is a funds destination: a typo'd one-step
535// transfer would strand all future fees.
536func NominateFeeAdmin(cur realm, nominee address) {
537	rejectStraySend(cur)
538	if cur.Previous().Address() != feeAdmin {
539		panic("only the fee admin can nominate a successor")
540	}
541	if nominee == "" {
542		pendingFeeAdmin = ""
543		return
544	}
545	if !nominee.IsValid() || string(nominee) != strings.ToLower(string(nominee)) {
546		panic("invalid nominee address: " + string(nominee))
547	}
548	pendingFeeAdmin = nominee
549}
550
551// AcceptFeeAdmin completes the handover; only the nominee can accept.
552func AcceptFeeAdmin(cur realm) {
553	rejectStraySend(cur)
554	if pendingFeeAdmin == "" || cur.Previous().Address() != pendingFeeAdmin {
555		panic("caller is not the pending fee admin")
556	}
557	feeAdmin = pendingFeeAdmin
558	pendingFeeAdmin = ""
559}
560
561// GetFeeInfo returns the current fee configuration and accrued total.
562func GetFeeInfo() string {
563	return "fee: " + formatPct(feeBps) + " (cap " + formatPct(MaxFeeBps) +
564		") | admin: " + string(feeAdmin) +
565		" | accrued: " + strconv.FormatInt(feesAccrued, 10) +
566		" | claimed: " + strconv.FormatInt(feesClaimed, 10)
567}
568
569// ---------- read-only queries ----------
570
571// GetSplitInfo returns a human-readable summary.
572func GetSplitInfo(splitID string) string {
573	s := mustGetSplit(splitID)
574
575	var b strings.Builder
576	b.WriteString("Split: " + splitID + "\n")
577	b.WriteString("Owner: " + string(s.Owner) + "\n")
578	if s.PendingOwner != "" {
579		// a nominee must be able to READ the offer before spending gas
580		// probing it with AcceptOwnership (v2 audit Y1-obs)
581		b.WriteString("Pending owner: " + string(s.PendingOwner) + "\n")
582	}
583	b.WriteString("Frozen: " + boolStr(s.Frozen) + "\n")
584	b.WriteString("Archived: " + boolStr(s.Archived) + "\n")
585	b.WriteString("Total deposited: " + strconv.FormatInt(s.TotalDeposited, 10) + "\n")
586	b.WriteString("Total claimed: " + strconv.FormatInt(s.TotalClaimed, 10) + "\n")
587	b.WriteString("Recipients:\n")
588	for i, r := range s.Recipients {
589		b.WriteString("  " + string(r) + "  " + formatPct(s.Shares[i]) + "  claimable: " + strconv.FormatInt(s.Balances[r], 10) + "\n")
590	}
591	return b.String()
592}
593
594// GetPendingOwner returns the staged nominee for a split, or "" when no
595// handover is pending. The consent half of a two-step transfer only means
596// something if the nominee can inspect the offer without paying gas to
597// probe it (v2 audit Y1-obs); this is that read path.
598func GetPendingOwner(splitID string) string {
599	return string(mustGetSplit(splitID).PendingOwner)
600}
601
602// GetClaimable returns claimable balance for an address.
603func GetClaimable(splitID string, addr address) int64 {
604	s := mustGetSplit(splitID)
605	return s.Balances[addr]
606}
607
608// ---------- render ----------
609
610// Render returns a markdown overview. Never panics.
611func Render(path string) string {
612	if len(splitIDs) == 0 {
613		return "# Fee Split\n\nNo active splits.\n"
614	}
615
616	var b strings.Builder
617	b.WriteString("# Fee Split\n\n")
618	if feeBps > 0 {
619		b.WriteString("**Protocol fee:** " + formatPct(feeBps) + " (hard cap " + formatPct(MaxFeeBps) + ")\n\n")
620	}
621
622	show := splitIDs
623	if len(show) > MaxRenderSplits {
624		b.WriteString("_Showing the most recent " + strconv.Itoa(MaxRenderSplits) + " active splits._\n\n")
625		show = show[len(show)-MaxRenderSplits:]
626	}
627	for _, id := range show {
628		s := splits[id]
629		b.WriteString("## " + id + "\n\n")
630		if s == nil {
631			b.WriteString("_(invalid)_\n\n")
632			continue
633		}
634		b.WriteString("```\n")
635		b.WriteString("Owner: " + string(s.Owner) + "\n")
636		if s.PendingOwner != "" {
637			b.WriteString("Pending owner: " + string(s.PendingOwner) + "\n")
638		}
639		b.WriteString("Frozen: " + boolStr(s.Frozen) + "\n")
640		b.WriteString("Total deposited: " + strconv.FormatInt(s.TotalDeposited, 10) + "\n")
641		b.WriteString("Total claimed: " + strconv.FormatInt(s.TotalClaimed, 10) + "\n")
642		if len(s.Recipients) > 0 {
643			b.WriteString("Recipients:\n")
644			for i, r := range s.Recipients {
645				share := "??%"
646				if i < len(s.Shares) {
647					share = formatPct(s.Shares[i])
648				}
649				bal := "0"
650				if s.Balances != nil {
651					bal = strconv.FormatInt(s.Balances[r], 10)
652				}
653				b.WriteString("  " + string(r) + "  " + share + "  claimable: " + bal + "\n")
654			}
655		}
656		b.WriteString("```\n\n")
657	}
658	return b.String()
659}
660

Raw Package Data

Raw JSON data