Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}