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}