splitter.gno
4.49 Kb · 181 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 "errors"
12 "strconv"
13 "strings"
14
15 "chain"
16 "chain/runtime/unsafe"
17
18 "gno.land/p/nt/avl/v0"
19)
20
21// payee is a single share holder inside a group.
22type payee struct {
23 addr address
24 shares int
25}
26
27// group is one payment-splitting arrangement.
28type group struct {
29 id int
30 owner address
31 payees []payee
32 totalShares int
33 pool int // total income recorded, in abstract units
34}
35
36var (
37 groups avl.Tree // id (zero-padded string) -> *group
38 nextID int
39 errEmpty = errors.New("splitter: no payees")
40)
41
42// Register creates a new group from comma-separated payees and shares.
43// payees: "g1abc...,g1def..." (addresses)
44// shares: "3,1" (positive integers, same count as payees)
45// Returns the new group id. Panics on malformed input.
46func Register(cur realm, payees string, shares string) int {
47 caller := unsafe.PreviousRealm().Address()
48
49 addrParts := splitTrim(payees)
50 shareParts := splitTrim(shares)
51
52 if len(addrParts) == 0 {
53 panic(errEmpty)
54 }
55 if len(addrParts) != len(shareParts) {
56 panic("splitter: payees and shares count mismatch")
57 }
58
59 g := &group{
60 id: nextID,
61 owner: caller,
62 }
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 groups.Set(key(g.id), g)
82 nextID++
83
84 chain.Emit(
85 "GroupRegistered",
86 "id", strconv.Itoa(g.id),
87 "payees", strconv.Itoa(len(g.payees)),
88 "totalShares", strconv.Itoa(g.totalShares),
89 )
90 return g.id
91}
92
93// RecordIncome adds amount to the pool of group id. amount must be positive.
94func RecordIncome(cur realm, id int, amount int) {
95 if amount <= 0 {
96 panic("splitter: amount must be positive")
97 }
98 v, ok := groups.Get(key(id))
99 if !ok {
100 panic("splitter: unknown group id: " + strconv.Itoa(id))
101 }
102 g := v.(*group)
103 g.pool += amount
104 groups.Set(key(id), g)
105
106 chain.Emit(
107 "IncomeRecorded",
108 "id", strconv.Itoa(id),
109 "amount", strconv.Itoa(amount),
110 "pool", strconv.Itoa(g.pool),
111 )
112}
113
114// owed returns the amount owed to a payee: pool * shares / totalShares.
115func (g *group) owed(p payee) int {
116 if g.totalShares == 0 {
117 return 0
118 }
119 return g.pool * p.shares / g.totalShares
120}
121
122// Render shows all groups, their payees, shares, and computed owed amounts.
123func Render(path string) string {
124 if groups.Size() == 0 {
125 return "# Payment Splitter\n\n_No groups registered yet._\n"
126 }
127
128 var b strings.Builder
129 b.WriteString("# Payment Splitter\n\n")
130 b.WriteString("Share-based accounting. `owed = pool * share / totalShares`.\n\n")
131
132 groups.Iterate("", "", func(k string, v interface{}) bool {
133 g := v.(*group)
134 b.WriteString("## Group #" + strconv.Itoa(g.id) + "\n\n")
135 b.WriteString("- Owner: `" + g.owner.String() + "`\n")
136 b.WriteString("- Pool: " + strconv.Itoa(g.pool) + "\n")
137 b.WriteString("- Total shares: " + strconv.Itoa(g.totalShares) + "\n\n")
138
139 b.WriteString("| Payee | Shares | Owed |\n")
140 b.WriteString("|---|---:|---:|\n")
141 distributed := 0
142 for _, p := range g.payees {
143 o := g.owed(p)
144 distributed += o
145 b.WriteString("| `" + p.addr.String() + "` | " +
146 strconv.Itoa(p.shares) + " | " + strconv.Itoa(o) + " |\n")
147 }
148 remainder := g.pool - distributed
149 if remainder > 0 {
150 b.WriteString("| _remainder (rounding)_ | | " +
151 strconv.Itoa(remainder) + " |\n")
152 }
153 b.WriteString("\n")
154 return false
155 })
156
157 return b.String()
158}
159
160// splitTrim splits on commas and trims whitespace around each element.
161func splitTrim(s string) []string {
162 if strings.TrimSpace(s) == "" {
163 return nil
164 }
165 parts := strings.Split(s, ",")
166 out := make([]string, 0, len(parts))
167 for _, p := range parts {
168 out = append(out, strings.TrimSpace(p))
169 }
170 return out
171}
172
173// key produces a zero-padded, lexicographically-sortable avl key for an id,
174// so Render iterates groups in ascending numeric order.
175func key(id int) string {
176 s := strconv.Itoa(id)
177 for len(s) < 12 {
178 s = "0" + s
179 }
180 return s
181}