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