memba_dao_candidature_v2.gno
14.02 Kb · 457 lines
1package memba_dao_candidature_v2
2
3// memba_dao_candidature — Membership application realm for MembaDAO.
4//
5// Provides a transparent, on-chain candidature flow:
6// 1. Applicant calls Apply(bio, skills) with a deposit
7// 2. Application is stored on-chain (visible to all)
8// 3. Any DAO member can create an AddMember proposal referencing the application
9// 4. DAO votes on the proposal (66% threshold)
10// 5. If approved, member is added via basedao's built-in AddMember handler
11//
12// Anti-spam:
13// - Required deposit (10 GNOT default, 10x increase on re-application)
14// - Max bio/skills length (5000 chars)
15// - Cannot apply if already a member
16//
17// Security:
18// - Deposits are held in the realm until withdrawn by applicant
19// - MarkApproved/MarkRejected restricted to admin allowlist (owner-managed)
20// - Owner can add/remove admins and transfer ownership
21// - Admin cap of 20 addresses to limit blast radius
22
23import (
24 "chain"
25 "chain/banker"
26 "chain/runtime"
27 "chain/runtime/unsafe"
28 "strconv"
29 "strings"
30
31 "gno.land/p/nt/avl/v0"
32 "gno.land/p/nt/ufmt/v0"
33)
34
35// ── Constants ────────────────────────────────────────────────
36
37const (
38 MinDeposit int64 = 10_000_000 // 10 GNOT in ugnot
39 DepositMultiply int64 = 10 // 10x on re-application
40 MaxApplyCount = 10 // Maximum re-applications (prevents int64 overflow at 10^17)
41 MaxBioLen = 5000
42 MaxSkillsLen = 5000
43 MaxAdmins = 20 // Maximum number of admin addresses
44 MaxApplications = 1000 // Global cap to prevent unbounded state growth
45)
46
47// ── Types ────────────────────────────────────────────────────
48
49type Application struct {
50 Address address
51 Bio string
52 Skills string
53 Deposit int64
54 Status string // "pending", "approved", "rejected", "withdrawn"
55 AppliedAt int64 // block height
56 ApplyCount int // number of times applied (for deposit scaling)
57}
58
59// ── State ────────────────────────────────────────────────────
60
61var (
62 applications *avl.Tree // address -> *Application
63 applyCount *avl.Tree // address -> int (total apply count, persists across withdrawals)
64 admins *avl.Tree // address -> bool (authorized callers for MarkApproved/MarkRejected)
65 paused bool
66 // Owner is set at package load time via OriginCaller() — this captures the
67 // deployer address (samcrew-core-test1 multisig on testnet).
68 owner = unsafe.OriginCaller()
69)
70
71func init() {
72 applications = avl.NewTree()
73 applyCount = avl.NewTree()
74 admins = avl.NewTree()
75 admins.Set(owner.String(), true)
76}
77
78// ── Emergency Pause ────────────────────────────────────────
79
80func assertNotPaused() {
81 if paused {
82 panic("realm is paused — emergency maintenance")
83 }
84}
85
86func Pause(cur realm) {
87 assertCallerIsOwner()
88 paused = true
89}
90
91func Unpause(cur realm) {
92 assertCallerIsOwner()
93 paused = false
94}
95
96func IsPaused() bool { return paused }
97
98// ── Public API ───────────────────────────────────────────────
99
100// Apply submits a membership application with a deposit.
101// The caller must send at least MinDeposit * (10 ^ previousAttempts) ugnot.
102func Apply(cur realm, bio string, skills string) {
103 assertNotPaused()
104 caller := unsafe.PreviousRealm().Address()
105
106 // Validate inputs
107 if len(bio) == 0 {
108 panic("bio cannot be empty")
109 }
110 if len(bio) > MaxBioLen {
111 panic(ufmt.Sprintf("bio too long: %d/%d chars", len(bio), MaxBioLen))
112 }
113 if len(skills) > MaxSkillsLen {
114 panic(ufmt.Sprintf("skills too long: %d/%d chars", len(skills), MaxSkillsLen))
115 }
116
117 // Check for existing pending application
118 isNewApplicant := true
119 if val, exists := applications.Get(caller.String()); exists {
120 isNewApplicant = false
121 app := val.(*Application)
122 if app.Status == "pending" {
123 panic("you already have a pending application")
124 }
125 }
126 // Enforce global cap only for truly new applicants (not re-applications)
127 if isNewApplicant && applications.Size() >= MaxApplications {
128 panic(ufmt.Sprintf("application limit reached: %d", MaxApplications))
129 }
130
131 // Calculate required deposit (10x per previous attempt, capped to prevent overflow)
132 count := getApplyCount(caller)
133 if count >= MaxApplyCount {
134 panic(ufmt.Sprintf("maximum re-application limit reached (%d)", MaxApplyCount))
135 }
136 required := MinDeposit
137 for i := 0; i < count; i++ {
138 required *= DepositMultiply
139 }
140
141 // Verify deposit (accumulate to handle multi-denom entries defensively)
142 sent := unsafe.OriginSend()
143 sentAmount := int64(0)
144 for _, coin := range sent {
145 if coin.Denom == "ugnot" {
146 sentAmount += coin.Amount
147 }
148 }
149 if sentAmount < required {
150 panic(ufmt.Sprintf("insufficient deposit: sent %d, required %d ugnot", sentAmount, required))
151 }
152
153 // Store application
154 app := &Application{
155 Address: caller,
156 Bio: bio,
157 Skills: skills,
158 Deposit: sentAmount,
159 Status: "pending",
160 AppliedAt: runtime.ChainHeight(),
161 ApplyCount: count + 1,
162 }
163 applications.Set(caller.String(), app)
164 applyCount.Set(caller.String(), count+1)
165
166 chain.Emit("ApplicationSubmitted",
167 "applicant", caller.String(),
168 "deposit", strconv.FormatInt(sentAmount, 10),
169 "attempt", strconv.Itoa(count+1),
170 )
171}
172
173// Withdraw allows an applicant to withdraw their pending application and reclaim deposit.
174func Withdraw(cur realm) {
175 caller := unsafe.PreviousRealm().Address()
176
177 val, exists := applications.Get(caller.String())
178 if !exists {
179 panic("no application found")
180 }
181 app := val.(*Application)
182 if app.Status != "pending" {
183 panic("can only withdraw pending applications")
184 }
185
186 // STATE-BEFORE-SEND: update status and zero deposit before coin transfer
187 app.Status = "withdrawn"
188 deposit := app.Deposit
189 app.Deposit = 0
190 applications.Set(caller.String(), app)
191
192 // Return deposit
193 if deposit > 0 {
194 b := banker.NewBanker(banker.BankerTypeRealmSend, cur)
195 b.SendCoins(unsafe.CurrentRealm().Address(), caller, chain.Coins{chain.NewCoin("ugnot", deposit)})
196 }
197}
198
199// MarkApproved is called by authorized admins to mark an application as approved.
200// Only addresses in the admin allowlist can call this function.
201func MarkApproved(cur realm, applicant address) {
202 assertCallerIsAdmin()
203
204 val, exists := applications.Get(applicant.String())
205 if !exists {
206 panic("no application found for " + applicant.String())
207 }
208 app := val.(*Application)
209 if app.Status != "pending" {
210 panic("application is not pending")
211 }
212 // STATE-BEFORE-SEND: update status before returning deposit
213 app.Status = "approved"
214 deposit := app.Deposit
215 app.Deposit = 0
216 applications.Set(applicant.String(), app)
217
218 // Return deposit on approval (same as rejection)
219 if deposit > 0 {
220 b := banker.NewBanker(banker.BankerTypeRealmSend, cur)
221 b.SendCoins(unsafe.CurrentRealm().Address(), applicant, chain.Coins{chain.NewCoin("ugnot", deposit)})
222 }
223
224 chain.Emit("ApplicationApproved",
225 "applicant", applicant.String(),
226 "approvedBy", unsafe.PreviousRealm().Address().String(),
227 )
228}
229
230// MarkRejected is called by authorized admins to mark an application as rejected.
231// Only addresses in the admin allowlist can call this function.
232func MarkRejected(cur realm, applicant address) {
233 assertCallerIsAdmin()
234
235 val, exists := applications.Get(applicant.String())
236 if !exists {
237 panic("no application found for " + applicant.String())
238 }
239 app := val.(*Application)
240 if app.Status != "pending" {
241 panic("application is not pending")
242 }
243 // STATE-BEFORE-SEND: update status and zero deposit before coin transfer
244 app.Status = "rejected"
245 deposit := app.Deposit
246 app.Deposit = 0
247 applications.Set(applicant.String(), app)
248
249 // Return deposit on rejection
250 if deposit > 0 {
251 b := banker.NewBanker(banker.BankerTypeRealmSend, cur)
252 b.SendCoins(unsafe.CurrentRealm().Address(), applicant, chain.Coins{chain.NewCoin("ugnot", deposit)})
253 }
254
255 chain.Emit("ApplicationRejected",
256 "applicant", applicant.String(),
257 "rejectedBy", unsafe.PreviousRealm().Address().String(),
258 )
259}
260
261// ── Admin Management ────────────────────────────────────────
262
263// AddAdmin adds an address to the admin allowlist. Only the owner can call this.
264func AddAdmin(cur realm, addr address) {
265 assertCallerIsOwner()
266 assertValidAddress(addr)
267 if admins.Size() >= MaxAdmins {
268 panic(ufmt.Sprintf("admin limit reached: %d/%d", admins.Size(), MaxAdmins))
269 }
270 admins.Set(addr.String(), true)
271}
272
273// RemoveAdmin removes an address from the admin allowlist. Only the owner can call this.
274func RemoveAdmin(cur realm, addr address) {
275 assertCallerIsOwner()
276 if addr == owner {
277 panic("cannot remove owner from admins")
278 }
279 if _, exists := admins.Get(addr.String()); !exists {
280 panic("address is not an admin: " + addr.String())
281 }
282 admins.Remove(addr.String())
283}
284
285// TransferOwnership transfers realm ownership to a new address.
286// The new owner is also added as admin. The old owner remains as admin
287// (the new owner can remove them via RemoveAdmin if desired).
288// Only the current owner can call this.
289func TransferOwnership(cur realm, newOwner address) {
290 assertCallerIsOwner()
291 assertValidAddress(newOwner)
292 if newOwner == owner {
293 panic("new owner is the same as current owner")
294 }
295 admins.Set(newOwner.String(), true)
296 owner = newOwner
297}
298
299// IsAdmin returns whether an address is in the admin allowlist.
300func IsAdmin(addr address) bool {
301 _, exists := admins.Get(addr.String())
302 return exists
303}
304
305// GetOwner returns the current realm owner address.
306func GetOwner() address {
307 return owner
308}
309
310// ListAdmins returns all admin addresses as a comma-separated string.
311func ListAdmins() string {
312 var addrs []string
313 admins.Iterate("", "", func(key string, value any) bool {
314 addrs = append(addrs, key)
315 return false
316 })
317 return strings.Join(addrs, ",")
318}
319
320// ── Queries ──────────────────────────────────────────────────
321
322// GetApplication returns the application for a given address (or empty if none).
323func GetApplication(addr string) string {
324 val, exists := applications.Get(addr)
325 if !exists {
326 return ""
327 }
328 app := val.(*Application)
329 return ufmt.Sprintf("%s|%s|%s|%d|%s|%d|%d",
330 app.Address, app.Bio, app.Skills, app.Deposit, app.Status, app.AppliedAt, app.ApplyCount)
331}
332
333// ── Render ───────────────────────────────────────────────────
334
335func Render(path string) string {
336 if path == "" {
337 return renderHome()
338 }
339 if strings.HasPrefix(path, "application/") {
340 addr := strings.TrimPrefix(path, "application/")
341 return renderApplication(addr)
342 }
343 return "# 404\nPage not found: " + path
344}
345
346func renderHome() string {
347 var sb strings.Builder
348 sb.WriteString("# MembaDAO Candidature\n\n")
349 sb.WriteString("Apply to join the Memba community.\n\n")
350
351 // Count by status
352 pending, approved, rejected := 0, 0, 0
353 applications.Iterate("", "", func(key string, value any) bool {
354 app := value.(*Application)
355 switch app.Status {
356 case "pending":
357 pending++
358 case "approved":
359 approved++
360 case "rejected":
361 rejected++
362 }
363 return false
364 })
365
366 sb.WriteString(ufmt.Sprintf("**Stats:** %d pending | %d approved | %d rejected\n", pending, approved, rejected))
367 sb.WriteString(ufmt.Sprintf("**Owner:** %s | **Admins:** %d\n\n", owner, admins.Size()))
368
369 // List pending applications
370 if pending > 0 {
371 sb.WriteString("## Pending Applications\n\n")
372 applications.Iterate("", "", func(key string, value any) bool {
373 app := value.(*Application)
374 if app.Status == "pending" {
375 sb.WriteString(ufmt.Sprintf("- [%s](:application/%s) — deposit: %s GNOT — block %d\n",
376 app.Address, app.Address, formatGNOT(app.Deposit), app.AppliedAt))
377 }
378 return false
379 })
380 }
381
382 return sb.String()
383}
384
385func renderApplication(addr string) string {
386 val, exists := applications.Get(addr)
387 if !exists {
388 return "# Application Not Found\nNo application for " + addr
389 }
390 app := val.(*Application)
391
392 var sb strings.Builder
393 sb.WriteString(ufmt.Sprintf("# Application: %s\n\n", app.Address))
394 sb.WriteString(ufmt.Sprintf("**Status:** %s\n", app.Status))
395 sb.WriteString(ufmt.Sprintf("**Deposit:** %s GNOT\n", formatGNOT(app.Deposit)))
396 sb.WriteString(ufmt.Sprintf("**Applied at block:** %d\n", app.AppliedAt))
397 sb.WriteString(ufmt.Sprintf("**Attempt #:** %d\n\n", app.ApplyCount))
398 sb.WriteString("## Bio\n\n")
399 sb.WriteString(sanitizeForRender(app.Bio) + "\n\n")
400 sb.WriteString("## Skills\n\n")
401 sb.WriteString(sanitizeForRender(app.Skills) + "\n")
402
403 return sb.String()
404}
405
406// ── Helpers ──────────────────────────────────────────────────
407
408// sanitizeForRender strips markdown-sensitive characters to prevent injection.
409func sanitizeForRender(s string) string {
410 var out strings.Builder
411 for _, c := range s {
412 switch c {
413 case '[', ']', '(', ')', '#', '*', '`', '!', '<', '>', '|', '\\', '_', '~', '\n', '\r', '\t':
414 continue
415 default:
416 out.WriteRune(c)
417 }
418 }
419 return out.String()
420}
421
422func assertCallerIsAdmin() {
423 caller := unsafe.PreviousRealm().Address()
424 if _, exists := admins.Get(caller.String()); !exists {
425 panic("unauthorized: caller " + caller.String() + " is not an admin")
426 }
427}
428
429func assertCallerIsOwner() {
430 caller := unsafe.PreviousRealm().Address()
431 if caller != owner {
432 panic("unauthorized: caller " + caller.String() + " is not the owner")
433 }
434}
435
436func assertValidAddress(addr address) {
437 if addr == "" {
438 panic("address cannot be empty")
439 }
440}
441
442func getApplyCount(addr address) int {
443 val, exists := applyCount.Get(addr.String())
444 if !exists {
445 return 0
446 }
447 return val.(int)
448}
449
450func formatGNOT(ugnot int64) string {
451 gnot := ugnot / 1_000_000
452 remainder := ugnot % 1_000_000
453 if remainder == 0 {
454 return strconv.FormatInt(gnot, 10)
455 }
456 return ufmt.Sprintf("%d.%06d", gnot, remainder)
457}