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

fee_split.gno

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