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}
660Raw Package Data
Raw JSON data