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.

fee_split.gno

20.58 Kb · 659 lines
  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}