splitter.gno
4.37 Kb · 165 lines
1// Package splitter is a share-based payment splitter for gno.land.
2//
3// It is an accounting-only port of the Solidity PaymentSplitter pattern:
4// no real coins move. A group is registered with a list of payees and a
5// matching list of integer shares. Income recorded against a group is
6// pooled, and each payee is owed a slice of that pool proportional to
7// their shares: owed = pool * share / totalShares.
8package splitter
9
10import (
11 "chain/markdown"
12 "errors"
13 "strconv"
14 "strings"
15
16 "chain"
17 "chain/runtime/unsafe"
18
19 "gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/kit/store/v0"
20)
21
22// payee is a single share holder inside a group.
23type payee struct {
24 addr address
25 shares int
26}
27
28// group is one payment-splitting arrangement. It carries no id field: the id
29// belongs to the store, which hands it back on lookup and iteration.
30type group struct {
31 owner address
32 payees []payee
33 totalShares int
34 pool int // total income recorded, in abstract units
35}
36
37var (
38 // groups assigns the group ids. v0 kept its own nextID plus a key() that
39 // zero-padded to width 12, which stopped ordering Render past 10^12.
40 groups = store.Named("splitter: group")
41
42 errEmpty = errors.New("splitter: no payees")
43)
44
45// Register creates a new group from comma-separated payees and shares.
46// payees: "g1abc...,g1def..." (addresses)
47// shares: "3,1" (positive integers, same count as payees)
48// Returns the new group id. Panics on malformed input.
49func Register(cur realm, payees string, shares string) int {
50 caller := unsafe.PreviousRealm().Address()
51
52 addrParts := splitTrim(payees)
53 shareParts := splitTrim(shares)
54
55 if len(addrParts) == 0 {
56 panic(errEmpty)
57 }
58 if len(addrParts) != len(shareParts) {
59 panic("splitter: payees and shares count mismatch")
60 }
61
62 g := &group{owner: caller}
63 for i := range addrParts {
64 if addrParts[i] == "" {
65 panic("splitter: empty payee address")
66 }
67 s, err := strconv.Atoi(shareParts[i])
68 if err != nil {
69 panic("splitter: bad share value: " + shareParts[i])
70 }
71 if s <= 0 {
72 panic("splitter: share must be positive")
73 }
74 g.payees = append(g.payees, payee{
75 addr: address(addrParts[i]),
76 shares: s,
77 })
78 g.totalShares += s
79 }
80
81 id := groups.Add(g)
82
83 chain.Emit(
84 "GroupRegistered",
85 "id", id.String(),
86 "payees", strconv.Itoa(len(g.payees)),
87 "totalShares", strconv.Itoa(g.totalShares),
88 )
89 return int(id)
90}
91
92// RecordIncome adds amount to the pool of group id. amount must be positive.
93func RecordIncome(cur realm, id int, amount int) {
94 if amount <= 0 {
95 panic("splitter: amount must be positive")
96 }
97 g := groups.MustGet(store.ID(id)).(*group)
98 g.pool += amount
99
100 chain.Emit(
101 "IncomeRecorded",
102 "id", strconv.Itoa(id),
103 "amount", strconv.Itoa(amount),
104 "pool", strconv.Itoa(g.pool),
105 )
106}
107
108// owed returns the amount owed to a payee: pool * shares / totalShares.
109func (g *group) owed(p payee) int {
110 if g.totalShares == 0 {
111 return 0
112 }
113 return g.pool * p.shares / g.totalShares
114}
115
116// Render shows all groups, their payees, shares, and computed owed amounts.
117func Render(path string) string {
118 if groups.Len() == 0 {
119 return "# Payment Splitter\n\n_No groups registered yet._\n"
120 }
121
122 var b strings.Builder
123 b.WriteString("# Payment Splitter\n\n")
124 b.WriteString("Share-based accounting. `owed = pool * share / totalShares`.\n\n")
125
126 groups.Each(func(id store.ID, v any) {
127 g := v.(*group)
128 b.WriteString("## Group #" + id.String() + "\n\n")
129 b.WriteString("- Owner: `" + g.owner.String() + "`\n")
130 b.WriteString("- Pool: " + strconv.Itoa(g.pool) + "\n")
131 b.WriteString("- Total shares: " + strconv.Itoa(g.totalShares) + "\n\n")
132
133 b.WriteString("| Payee | Shares | Owed |\n")
134 b.WriteString("|---|---:|---:|\n")
135 distributed := 0
136 for _, p := range g.payees {
137 o := g.owed(p)
138 distributed += o
139 b.WriteString("| `" + p.addr.String() + "` | " +
140 strconv.Itoa(p.shares) + " | " + strconv.Itoa(o) + " |\n")
141 }
142 remainder := g.pool - distributed
143 if remainder > 0 {
144 b.WriteString("| _remainder (rounding)_ | | " +
145 strconv.Itoa(remainder) + " |\n")
146 }
147 b.WriteString("\n")
148 })
149
150 return b.String()
151}
152
153// splitTrim splits on commas and trims whitespace around each element.
154func splitTrim(s string) []string {
155 if strings.TrimSpace(markdown.StripBidiAndZeroWidth(s)) == "" {
156 return nil
157 }
158 parts := strings.Split(s, ",")
159 out := make([]string, 0, len(parts))
160 for _, p := range parts {
161 out = append(out, strings.TrimSpace(p))
162 }
163 return out
164}
165