Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}