memba_dao_candidature_v3.gno
14.71 Kb · 469 lines
1package memba_dao_candidature_v3
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
105 // P0 fund-guard: a payable entrypoint MUST be a direct user call. unsafe.OriginSend()
106 // reports the tx-level `--send`, which is credited to the DIRECT message target realm
107 // — not to an inner realm reached via a cross-call. Without this guard an attacker
108 // routes Apply through their own intermediary realm (coins land there), records a
109 // phantom Deposit this realm never received, then drains real pooled deposits via
110 // Withdraw. Requiring a direct user call forces this realm to be the message target,
111 // the one condition under which the OriginSend coins are guaranteed to be in its account.
112 if !unsafe.PreviousRealm().IsUserCall() {
113 panic("Apply must be a direct user call")
114 }
115
116 caller := unsafe.PreviousRealm().Address()
117
118 // Validate inputs
119 if len(bio) == 0 {
120 panic("bio cannot be empty")
121 }
122 if len(bio) > MaxBioLen {
123 panic(ufmt.Sprintf("bio too long: %d/%d chars", len(bio), MaxBioLen))
124 }
125 if len(skills) > MaxSkillsLen {
126 panic(ufmt.Sprintf("skills too long: %d/%d chars", len(skills), MaxSkillsLen))
127 }
128
129 // Check for existing pending application
130 isNewApplicant := true
131 if val, exists := applications.Get(caller.String()); exists {
132 isNewApplicant = false
133 app := val.(*Application)
134 if app.Status == "pending" {
135 panic("you already have a pending application")
136 }
137 }
138 // Enforce global cap only for truly new applicants (not re-applications)
139 if isNewApplicant && applications.Size() >= MaxApplications {
140 panic(ufmt.Sprintf("application limit reached: %d", MaxApplications))
141 }
142
143 // Calculate required deposit (10x per previous attempt, capped to prevent overflow)
144 count := getApplyCount(caller)
145 if count >= MaxApplyCount {
146 panic(ufmt.Sprintf("maximum re-application limit reached (%d)", MaxApplyCount))
147 }
148 required := MinDeposit
149 for i := 0; i < count; i++ {
150 required *= DepositMultiply
151 }
152
153 // Verify deposit (accumulate to handle multi-denom entries defensively)
154 sent := unsafe.OriginSend()
155 sentAmount := int64(0)
156 for _, coin := range sent {
157 if coin.Denom == "ugnot" {
158 sentAmount += coin.Amount
159 }
160 }
161 if sentAmount < required {
162 panic(ufmt.Sprintf("insufficient deposit: sent %d, required %d ugnot", sentAmount, required))
163 }
164
165 // Store application
166 app := &Application{
167 Address: caller,
168 Bio: bio,
169 Skills: skills,
170 Deposit: sentAmount,
171 Status: "pending",
172 AppliedAt: runtime.ChainHeight(),
173 ApplyCount: count + 1,
174 }
175 applications.Set(caller.String(), app)
176 applyCount.Set(caller.String(), count+1)
177
178 chain.Emit("ApplicationSubmitted",
179 "applicant", caller.String(),
180 "deposit", strconv.FormatInt(sentAmount, 10),
181 "attempt", strconv.Itoa(count+1),
182 )
183}
184
185// Withdraw allows an applicant to withdraw their pending application and reclaim deposit.
186func Withdraw(cur realm) {
187 caller := unsafe.PreviousRealm().Address()
188
189 val, exists := applications.Get(caller.String())
190 if !exists {
191 panic("no application found")
192 }
193 app := val.(*Application)
194 if app.Status != "pending" {
195 panic("can only withdraw pending applications")
196 }
197
198 // STATE-BEFORE-SEND: update status and zero deposit before coin transfer
199 app.Status = "withdrawn"
200 deposit := app.Deposit
201 app.Deposit = 0
202 applications.Set(caller.String(), app)
203
204 // Return deposit
205 if deposit > 0 {
206 b := banker.NewBanker(banker.BankerTypeRealmSend, cur)
207 b.SendCoins(unsafe.CurrentRealm().Address(), caller, chain.Coins{chain.NewCoin("ugnot", deposit)})
208 }
209}
210
211// MarkApproved is called by authorized admins to mark an application as approved.
212// Only addresses in the admin allowlist can call this function.
213func MarkApproved(cur realm, applicant address) {
214 assertCallerIsAdmin()
215
216 val, exists := applications.Get(applicant.String())
217 if !exists {
218 panic("no application found for " + applicant.String())
219 }
220 app := val.(*Application)
221 if app.Status != "pending" {
222 panic("application is not pending")
223 }
224 // STATE-BEFORE-SEND: update status before returning deposit
225 app.Status = "approved"
226 deposit := app.Deposit
227 app.Deposit = 0
228 applications.Set(applicant.String(), app)
229
230 // Return deposit on approval (same as rejection)
231 if deposit > 0 {
232 b := banker.NewBanker(banker.BankerTypeRealmSend, cur)
233 b.SendCoins(unsafe.CurrentRealm().Address(), applicant, chain.Coins{chain.NewCoin("ugnot", deposit)})
234 }
235
236 chain.Emit("ApplicationApproved",
237 "applicant", applicant.String(),
238 "approvedBy", unsafe.PreviousRealm().Address().String(),
239 )
240}
241
242// MarkRejected is called by authorized admins to mark an application as rejected.
243// Only addresses in the admin allowlist can call this function.
244func MarkRejected(cur realm, applicant address) {
245 assertCallerIsAdmin()
246
247 val, exists := applications.Get(applicant.String())
248 if !exists {
249 panic("no application found for " + applicant.String())
250 }
251 app := val.(*Application)
252 if app.Status != "pending" {
253 panic("application is not pending")
254 }
255 // STATE-BEFORE-SEND: update status and zero deposit before coin transfer
256 app.Status = "rejected"
257 deposit := app.Deposit
258 app.Deposit = 0
259 applications.Set(applicant.String(), app)
260
261 // Return deposit on rejection
262 if deposit > 0 {
263 b := banker.NewBanker(banker.BankerTypeRealmSend, cur)
264 b.SendCoins(unsafe.CurrentRealm().Address(), applicant, chain.Coins{chain.NewCoin("ugnot", deposit)})
265 }
266
267 chain.Emit("ApplicationRejected",
268 "applicant", applicant.String(),
269 "rejectedBy", unsafe.PreviousRealm().Address().String(),
270 )
271}
272
273// ── Admin Management ────────────────────────────────────────
274
275// AddAdmin adds an address to the admin allowlist. Only the owner can call this.
276func AddAdmin(cur realm, addr address) {
277 assertCallerIsOwner()
278 assertValidAddress(addr)
279 if admins.Size() >= MaxAdmins {
280 panic(ufmt.Sprintf("admin limit reached: %d/%d", admins.Size(), MaxAdmins))
281 }
282 admins.Set(addr.String(), true)
283}
284
285// RemoveAdmin removes an address from the admin allowlist. Only the owner can call this.
286func RemoveAdmin(cur realm, addr address) {
287 assertCallerIsOwner()
288 if addr == owner {
289 panic("cannot remove owner from admins")
290 }
291 if _, exists := admins.Get(addr.String()); !exists {
292 panic("address is not an admin: " + addr.String())
293 }
294 admins.Remove(addr.String())
295}
296
297// TransferOwnership transfers realm ownership to a new address.
298// The new owner is also added as admin. The old owner remains as admin
299// (the new owner can remove them via RemoveAdmin if desired).
300// Only the current owner can call this.
301func TransferOwnership(cur realm, newOwner address) {
302 assertCallerIsOwner()
303 assertValidAddress(newOwner)
304 if newOwner == owner {
305 panic("new owner is the same as current owner")
306 }
307 admins.Set(newOwner.String(), true)
308 owner = newOwner
309}
310
311// IsAdmin returns whether an address is in the admin allowlist.
312func IsAdmin(addr address) bool {
313 _, exists := admins.Get(addr.String())
314 return exists
315}
316
317// GetOwner returns the current realm owner address.
318func GetOwner() address {
319 return owner
320}
321
322// ListAdmins returns all admin addresses as a comma-separated string.
323func ListAdmins() string {
324 var addrs []string
325 admins.Iterate("", "", func(key string, value any) bool {
326 addrs = append(addrs, key)
327 return false
328 })
329 return strings.Join(addrs, ",")
330}
331
332// ── Queries ──────────────────────────────────────────────────
333
334// GetApplication returns the application for a given address (or empty if none).
335func GetApplication(addr string) string {
336 val, exists := applications.Get(addr)
337 if !exists {
338 return ""
339 }
340 app := val.(*Application)
341 return ufmt.Sprintf("%s|%s|%s|%d|%s|%d|%d",
342 app.Address, app.Bio, app.Skills, app.Deposit, app.Status, app.AppliedAt, app.ApplyCount)
343}
344
345// ── Render ───────────────────────────────────────────────────
346
347func Render(path string) string {
348 if path == "" {
349 return renderHome()
350 }
351 if strings.HasPrefix(path, "application/") {
352 addr := strings.TrimPrefix(path, "application/")
353 return renderApplication(addr)
354 }
355 return "# 404\nPage not found: " + path
356}
357
358func renderHome() string {
359 var sb strings.Builder
360 sb.WriteString("# MembaDAO Candidature\n\n")
361 sb.WriteString("Apply to join the Memba community.\n\n")
362
363 // Count by status
364 pending, approved, rejected := 0, 0, 0
365 applications.Iterate("", "", func(key string, value any) bool {
366 app := value.(*Application)
367 switch app.Status {
368 case "pending":
369 pending++
370 case "approved":
371 approved++
372 case "rejected":
373 rejected++
374 }
375 return false
376 })
377
378 sb.WriteString(ufmt.Sprintf("**Stats:** %d pending | %d approved | %d rejected\n", pending, approved, rejected))
379 sb.WriteString(ufmt.Sprintf("**Owner:** %s | **Admins:** %d\n\n", owner, admins.Size()))
380
381 // List pending applications
382 if pending > 0 {
383 sb.WriteString("## Pending Applications\n\n")
384 applications.Iterate("", "", func(key string, value any) bool {
385 app := value.(*Application)
386 if app.Status == "pending" {
387 sb.WriteString(ufmt.Sprintf("- [%s](:application/%s) — deposit: %s GNOT — block %d\n",
388 app.Address, app.Address, formatGNOT(app.Deposit), app.AppliedAt))
389 }
390 return false
391 })
392 }
393
394 return sb.String()
395}
396
397func renderApplication(addr string) string {
398 val, exists := applications.Get(addr)
399 if !exists {
400 return "# Application Not Found\nNo application for " + addr
401 }
402 app := val.(*Application)
403
404 var sb strings.Builder
405 sb.WriteString(ufmt.Sprintf("# Application: %s\n\n", app.Address))
406 sb.WriteString(ufmt.Sprintf("**Status:** %s\n", app.Status))
407 sb.WriteString(ufmt.Sprintf("**Deposit:** %s GNOT\n", formatGNOT(app.Deposit)))
408 sb.WriteString(ufmt.Sprintf("**Applied at block:** %d\n", app.AppliedAt))
409 sb.WriteString(ufmt.Sprintf("**Attempt #:** %d\n\n", app.ApplyCount))
410 sb.WriteString("## Bio\n\n")
411 sb.WriteString(sanitizeForRender(app.Bio) + "\n\n")
412 sb.WriteString("## Skills\n\n")
413 sb.WriteString(sanitizeForRender(app.Skills) + "\n")
414
415 return sb.String()
416}
417
418// ── Helpers ──────────────────────────────────────────────────
419
420// sanitizeForRender strips markdown-sensitive characters to prevent injection.
421func sanitizeForRender(s string) string {
422 var out strings.Builder
423 for _, c := range s {
424 switch c {
425 case '[', ']', '(', ')', '#', '*', '`', '!', '<', '>', '|', '\\', '_', '~', '\n', '\r', '\t':
426 continue
427 default:
428 out.WriteRune(c)
429 }
430 }
431 return out.String()
432}
433
434func assertCallerIsAdmin() {
435 caller := unsafe.PreviousRealm().Address()
436 if _, exists := admins.Get(caller.String()); !exists {
437 panic("unauthorized: caller " + caller.String() + " is not an admin")
438 }
439}
440
441func assertCallerIsOwner() {
442 caller := unsafe.PreviousRealm().Address()
443 if caller != owner {
444 panic("unauthorized: caller " + caller.String() + " is not the owner")
445 }
446}
447
448func assertValidAddress(addr address) {
449 if addr == "" {
450 panic("address cannot be empty")
451 }
452}
453
454func getApplyCount(addr address) int {
455 val, exists := applyCount.Get(addr.String())
456 if !exists {
457 return 0
458 }
459 return val.(int)
460}
461
462func formatGNOT(ugnot int64) string {
463 gnot := ugnot / 1_000_000
464 remainder := ugnot % 1_000_000
465 if remainder == 0 {
466 return strconv.FormatInt(gnot, 10)
467 }
468 return ufmt.Sprintf("%d.%06d", gnot, remainder)
469}