multisig.gno
5.49 Kb · 223 lines
1// Package multisig implements an M-of-N approval multisig realm.
2//
3// It is a port of the classic Solidity multisig idea: a fixed set of owners
4// is configured once, any owner may create a proposal, owners approve it, and
5// once the number of distinct approvals reaches the threshold the proposal is
6// marked "Executed". This realm tracks approvals only — it does not move funds.
7package multisig
8
9import (
10 "errors"
11 "strconv"
12 "strings"
13
14 "chain"
15 "chain/runtime/unsafe"
16
17 "gno.land/p/nt/avl/v0"
18)
19
20// State of a single proposal.
21type stateKind int
22
23const (
24 statePending stateKind = iota
25 stateExecuted
26)
27
28func (s stateKind) String() string {
29 if s == stateExecuted {
30 return "Executed"
31 }
32 return "Pending"
33}
34
35// proposal is a single approval request.
36type proposal struct {
37 id int
38 description string
39 proposer address
40 approvals *avl.Tree // owner address string -> struct{}{}, distinct approvers
41 state stateKind
42}
43
44// Package-level persistent state.
45var (
46 owners *avl.Tree // owner address string -> struct{}{}
47 ownerCount int
48 threshold int
49 initialized bool
50
51 proposals *avl.Tree // decimal id string -> *proposal
52 nextID int
53)
54
55var (
56 errAlreadyInit = errors.New("multisig: already initialized")
57 errNotInit = errors.New("multisig: not initialized")
58 errBadThreshold = errors.New("multisig: threshold must be between 1 and number of owners")
59 errNoOwners = errors.New("multisig: at least one owner required")
60 errNotOwner = errors.New("multisig: caller is not an owner")
61 errNoProposal = errors.New("multisig: proposal not found")
62 errDupApproval = errors.New("multisig: caller already approved this proposal")
63 errExecuted = errors.New("multisig: proposal already executed")
64)
65
66func init() {
67 owners = avl.NewTree()
68 proposals = avl.NewTree()
69}
70
71// caller returns the address of the realm/user that called into this realm.
72func caller() address {
73 return unsafe.PreviousRealm().Address()
74}
75
76func isOwner(a address) bool {
77 return owners.Has(a.String())
78}
79
80// Setup configures the multisig exactly once. owners is a comma-separated list
81// of bech32 addresses; threshold is the number of approvals required to execute
82// a proposal. It panics (cross-realm abort) on invalid input or if already set.
83func Setup(cur realm, ownersCSV string, thresholdN int) {
84 if initialized {
85 panic(errAlreadyInit)
86 }
87
88 parts := strings.Split(ownersCSV, ",")
89 count := 0
90 for _, p := range parts {
91 a := strings.TrimSpace(p)
92 if a == "" {
93 continue
94 }
95 addr := address(a)
96 if owners.Has(addr.String()) {
97 continue // dedupe
98 }
99 owners.Set(addr.String(), struct{}{})
100 count++
101 }
102 if count == 0 {
103 panic(errNoOwners)
104 }
105 if thresholdN < 1 || thresholdN > count {
106 panic(errBadThreshold)
107 }
108
109 ownerCount = count
110 threshold = thresholdN
111 initialized = true
112
113 chain.Emit("Setup", "owners", strconv.Itoa(count), "threshold", strconv.Itoa(thresholdN))
114}
115
116// Propose creates a new proposal authored by an owner and returns its id.
117func Propose(cur realm, description string) int {
118 if !initialized {
119 panic(errNotInit)
120 }
121 c := caller()
122 if !isOwner(c) {
123 panic(errNotOwner)
124 }
125
126 id := nextID
127 nextID++
128
129 p := &proposal{
130 id: id,
131 description: description,
132 proposer: c,
133 approvals: avl.NewTree(),
134 state: statePending,
135 }
136 proposals.Set(strconv.Itoa(id), p)
137
138 chain.Emit("Propose", "id", strconv.Itoa(id), "proposer", c.String())
139 return id
140}
141
142// Approve records an owner's approval for a proposal. When the count of distinct
143// approvals reaches the threshold, the proposal becomes Executed.
144func Approve(cur realm, id int) {
145 if !initialized {
146 panic(errNotInit)
147 }
148 c := caller()
149 if !isOwner(c) {
150 panic(errNotOwner)
151 }
152
153 v, ok := proposals.Get(strconv.Itoa(id))
154 if !ok {
155 panic(errNoProposal)
156 }
157 p := v.(*proposal)
158 if p.state == stateExecuted {
159 panic(errExecuted)
160 }
161 if p.approvals.Has(c.String()) {
162 panic(errDupApproval)
163 }
164
165 p.approvals.Set(c.String(), struct{}{})
166 chain.Emit("Approve", "id", strconv.Itoa(id), "owner", c.String(), "approvals", strconv.Itoa(p.approvals.Size()))
167
168 if p.approvals.Size() >= threshold {
169 p.state = stateExecuted
170 chain.Emit("Executed", "id", strconv.Itoa(id))
171 }
172}
173
174// Render returns a Markdown view of the multisig configuration and proposals.
175func Render(path string) string {
176 var b strings.Builder
177 b.WriteString("# M-of-N Multisig\n\n")
178
179 if !initialized {
180 b.WriteString("_Not initialized. Call `Setup(owners, threshold)` once._\n")
181 return b.String()
182 }
183
184 b.WriteString("**Threshold:** ")
185 b.WriteString(strconv.Itoa(threshold))
186 b.WriteString(" of ")
187 b.WriteString(strconv.Itoa(ownerCount))
188 b.WriteString("\n\n## Owners\n\n")
189 owners.Iterate("", "", func(key string, _ interface{}) bool {
190 b.WriteString("- `")
191 b.WriteString(key)
192 b.WriteString("`\n")
193 return false
194 })
195
196 b.WriteString("\n## Proposals\n\n")
197 if proposals.Size() == 0 {
198 b.WriteString("_No proposals yet._\n")
199 return b.String()
200 }
201
202 b.WriteString("| ID | Description | Proposer | Approvals | State |\n")
203 b.WriteString("|----|-------------|----------|-----------|-------|\n")
204 proposals.Iterate("", "", func(_ string, v interface{}) bool {
205 p := v.(*proposal)
206 b.WriteString("| ")
207 b.WriteString(strconv.Itoa(p.id))
208 b.WriteString(" | ")
209 b.WriteString(p.description)
210 b.WriteString(" | `")
211 b.WriteString(p.proposer.String())
212 b.WriteString("` | ")
213 b.WriteString(strconv.Itoa(p.approvals.Size()))
214 b.WriteString("/")
215 b.WriteString(strconv.Itoa(threshold))
216 b.WriteString(" | ")
217 b.WriteString(p.state.String())
218 b.WriteString(" |\n")
219 return false
220 })
221
222 return b.String()
223}