Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}