Search Apps Documentation Source Content File Folder Download Copy Actions Download

agent_registry.gno

24.51 Kb · 860 lines
  1package agent_registry
  2
  3// Agent Registry — On-chain AI Agent Marketplace for the Memba ecosystem.
  4//
  5// Agents self-register with MCP metadata (endpoint, transport, pricing).
  6// Anyone can query via Render() for agent listings.
  7// Reviews are stored on-chain with 1-5 star ratings.
  8// Pay-per-use agents support a prepaid credit system.
  9//
 10// Render() contract:
 11//
 12//   Home — Render(""):
 13//     # Memba Agent Registry
 14//     description
 15//     ## Agents
 16//     | ID | Name | Category | Rating | Pricing |
 17//     | --- | --- | --- | --- | --- |
 18//     | id | name | category | 4.2 (5) | free |
 19//
 20//   Agent — Render("agent/id"):
 21//     # AgentName
 22//     description
 23//     **Category:** category
 24//     **Creator:** g1addr
 25//     **Endpoint:** endpoint
 26//     **Transport:** stdio|sse|streamable-http
 27//     **Pricing:** free|pay-per-use|subscription
 28//     **Rating:** 4.2 (5 reviews)
 29//     ## Capabilities
 30//     - capability1
 31//     - capability2
 32//     ## Reviews
 33//     **g1addr...** stars (block H)
 34//     review text
 35//
 36//   Stats — Render("stats"):
 37//     Total agents, total reviews, total calls
 38
 39import (
 40	"chain"
 41	"chain/banker"
 42	"chain/runtime"
 43	"chain/runtime/unsafe"
 44	"strconv"
 45	"strings"
 46
 47	"gno.land/p/nt/avl/v0"
 48	"gno.land/p/nt/ufmt/v0"
 49)
 50
 51// ── Constants ────────────────────────────────────────────────
 52
 53const (
 54	MaxAgents    = 100
 55	MaxNameLen   = 100
 56	MaxDescLen   = 1000
 57	MaxCapsLen   = 2000
 58	MaxReviewLen = 500
 59	AdminAddress = "g1x7k4628w93a7wzdhqc06atzx0v50rnshweuxu0" // samcrew-core-test1 multisig
 60
 61	// AAA-1 B4 — anti-squat + fund-lock-DoS bounds.
 62	// MaxAgentsPerCreator caps how many agents one address can register, so a
 63	// single funded address cannot squat the whole MaxAgents global supply.
 64	MaxAgentsPerCreator = 10
 65	// MaxDepositorsPerAgent bounds the distinct depositor set per agent. RemoveAgent
 66	// refunds EVERY depositor in one transaction (the credits prefix scan below); an
 67	// unbounded set would make RemoveAgent exceed the block gas budget and become
 68	// permanently uncallable, locking all deposited funds. At this cap the full
 69	// refund loop measures ≈3M gas in `gno test` — a small fraction of the block
 70	// budget, so RemoveAgent stays callable with wide headroom.
 71	MaxDepositorsPerAgent = 50
 72)
 73
 74// ── Types ────────────────────────────────────────────────────
 75
 76type Agent struct {
 77	ID           string
 78	Name         string
 79	Description  string
 80	Category     string
 81	Capabilities string // comma-separated
 82	Creator      address
 83	Endpoint     string
 84	Transport    string // "stdio" | "sse" | "streamable-http"
 85	Pricing      string // "free" | "pay-per-use" | "subscription"
 86	PricePerCall int64  // in ugnot (0 if free)
 87	Version      string
 88	TotalCalls   int64
 89	RatingSum    int64
 90	RatingCount  int64
 91	BlockH       int64
 92}
 93
 94type Review struct {
 95	Reviewer address
 96	Rating   int // 1-5
 97	Comment  string
 98	BlockH   int64
 99}
100
101// ── State ────────────────────────────────────────────────────
102
103var (
104	agents    *avl.Tree // id -> *Agent
105	reviews   *avl.Tree // agentId -> []*Review
106	reviewers *avl.Tree // "agentId/addr" -> true (dedup: one review per agent per address)
107	credits   *avl.Tree // "agentId/userAddr" -> int64 (prepaid credits in ugnot)
108	usage     *avl.Tree // "agentId/userAddr" -> int64 (total calls)
109	earnings  *avl.Tree // agentId -> int64 (accumulated creator earnings in ugnot)
110	paused    bool
111)
112
113func init() {
114	agents = avl.NewTree()
115	reviews = avl.NewTree()
116	reviewers = avl.NewTree()
117	credits = avl.NewTree()
118	usage = avl.NewTree()
119	earnings = avl.NewTree()
120}
121
122// ── Emergency Pause ────────────────────────────────────────
123//
124// Pause policy (AAA-1 B3 — one policy, enforced everywhere):
125//   While paused, every state-mutating user operation is blocked
126//   (RegisterAgent, UpdateAgent, ReviewAgent, RemoveAgent, DepositCredits,
127//   UseCredit, WithdrawEarnings) via assertNotPaused().
128//
129//   EXEMPTION — RefundCredits is deliberately NOT gated: a user must always be
130//   able to reclaim their own deposited principal, even during emergency
131//   maintenance. A credit balance only ever equals real ugnot the user sent
132//   (DepositCredits) minus what they spent (UseCredit), so refunding it is
133//   always safe and never exploit-amplifying. WithdrawEarnings (creator profit,
134//   which an exploit could inflate) stays blocked by design.
135
136func assertNotPaused() {
137	if paused {
138		panic("realm is paused — emergency maintenance")
139	}
140}
141
142// Pause blocks all state-mutating operations EXCEPT RefundCredits (users keep the
143// right to reclaim their own deposits). Admin only. See the pause policy above.
144func Pause(cur realm) {
145	caller := unsafe.PreviousRealm().Address()
146	if caller != address(AdminAddress) {
147		panic("only admin can pause")
148	}
149	paused = true
150}
151
152// Unpause resumes normal operations. Admin only.
153func Unpause(cur realm) {
154	caller := unsafe.PreviousRealm().Address()
155	if caller != address(AdminAddress) {
156		panic("only admin can unpause")
157	}
158	paused = false
159}
160
161// IsPaused returns the current pause state.
162func IsPaused() bool {
163	return paused
164}
165
166// ── Public Functions ─────────────────────────────────────────
167
168// RegisterAgent registers a new agent in the registry.
169func RegisterAgent(
170	cur realm,
171	id, name, description, category, capabilities,
172	endpoint, transport, pricing, version string,
173	pricePerCall int64,
174) {
175	assertNotPaused()
176	caller := unsafe.PreviousRealm().Address()
177
178	if _, exists := agents.Get(id); exists {
179		panic("agent ID already exists: " + id)
180	}
181	if len(id) == 0 || len(id) > 50 {
182		panic("ID must be 1-50 characters")
183	}
184	if !isValidAgentID(id) {
185		panic("ID must contain only alphanumeric characters, hyphens, or underscores")
186	}
187	if len(name) == 0 || len(name) > MaxNameLen {
188		panic(ufmt.Sprintf("name must be 1-%d characters", MaxNameLen))
189	}
190	if len(description) > MaxDescLen {
191		panic("description too long")
192	}
193	if len(capabilities) > MaxCapsLen {
194		panic("capabilities too long")
195	}
196	if len(category) > MaxNameLen {
197		panic("category too long")
198	}
199	if len(endpoint) > MaxDescLen {
200		panic("endpoint too long")
201	}
202	if len(version) > 50 {
203		panic("version too long")
204	}
205	if agents.Size() >= MaxAgents {
206		panic("registry full")
207	}
208	// B4: per-creator cap — stop a single address squatting the global supply.
209	if countAgentsByCreator(caller) >= MaxAgentsPerCreator {
210		panic("creator agent limit reached")
211	}
212	if !isValidTransport(transport) {
213		panic("transport must be stdio, sse, or streamable-http")
214	}
215	if !isValidPricing(pricing) {
216		panic("pricing must be free, pay-per-use, or subscription")
217	}
218	if pricePerCall < 0 {
219		panic("pricePerCall must be >= 0")
220	}
221
222	a := &Agent{
223		ID:           id,
224		Name:         name,
225		Description:  description,
226		Category:     category,
227		Capabilities: capabilities,
228		Creator:      caller,
229		Endpoint:     endpoint,
230		Transport:    transport,
231		Pricing:      pricing,
232		PricePerCall: pricePerCall,
233		Version:      version,
234		BlockH:       runtime.ChainHeight(),
235	}
236	agents.Set(id, a)
237	reviews.Set(id, []*Review{})
238
239	chain.Emit("AgentRegistered",
240		"id", id,
241		"name", name,
242		"creator", caller.String(),
243		"pricing", pricing,
244	)
245}
246
247// UpdateAgent allows the creator to update their agent.
248func UpdateAgent(
249	cur realm,
250	id, description, capabilities, endpoint, version, pricing string,
251	pricePerCall int64,
252) {
253	assertNotPaused()
254	caller := unsafe.PreviousRealm().Address()
255	val, exists := agents.Get(id)
256	if !exists {
257		panic("agent not found: " + id)
258	}
259	a := val.(*Agent)
260	if a.Creator != caller {
261		panic("only the creator can update")
262	}
263	if len(description) > 0 {
264		if len(description) > MaxDescLen {
265			panic("description too long")
266		}
267		a.Description = description
268	}
269	if len(capabilities) > 0 {
270		if len(capabilities) > MaxCapsLen {
271			panic("capabilities too long")
272		}
273		a.Capabilities = capabilities
274	}
275	if len(endpoint) > 0 {
276		a.Endpoint = endpoint
277	}
278	if len(version) > 0 {
279		a.Version = version
280	}
281	if pricePerCall < 0 {
282		panic("pricePerCall must be >= 0")
283	}
284	if len(pricing) > 0 {
285		if !isValidPricing(pricing) {
286			panic("pricing must be free, pay-per-use, or subscription")
287		}
288		// Prevent pricing model change when ANY credit entry exists for this agent,
289		// including zero-balance entries. This closes the price-lock bypass where
290		// the creator drains user balances to 0 then raises the price before the
291		// user's next deposit lands.
292		if pricing != a.Pricing && hasAnyCreditsEntry(id) {
293			panic("cannot change pricing model while credit entries exist for this agent")
294		}
295		a.Pricing = pricing
296	}
297	if pricePerCall != a.PricePerCall && hasAnyCreditsEntry(id) {
298		panic("cannot change price per call while credit entries exist for this agent")
299	}
300	a.PricePerCall = pricePerCall
301	agents.Set(id, a)
302
303	chain.Emit("AgentUpdated",
304		"id", id,
305		"creator", caller.String(),
306	)
307}
308
309// ReviewAgent adds a review for an agent. One review per address per agent.
310func ReviewAgent(cur realm, agentId string, rating int, comment string) {
311	assertNotPaused()
312	caller := unsafe.PreviousRealm().Address()
313
314	val, exists := agents.Get(agentId)
315	if !exists {
316		panic("agent not found")
317	}
318	if rating < 1 || rating > 5 {
319		panic("rating must be 1-5")
320	}
321	if len(comment) > MaxReviewLen {
322		panic("review too long")
323	}
324
325	// Enforce one review per address per agent
326	reviewKey := agentId + "/" + caller.String()
327	if _, already := reviewers.Get(reviewKey); already {
328		panic("already reviewed this agent")
329	}
330	reviewers.Set(reviewKey, true)
331
332	a := val.(*Agent)
333	a.RatingSum += int64(rating)
334	a.RatingCount++
335	agents.Set(agentId, a)
336
337	rval, _ := reviews.Get(agentId)
338	revs := rval.([]*Review)
339	revs = append(revs, &Review{
340		Reviewer: caller,
341		Rating:   rating,
342		Comment:  comment,
343		BlockH:   runtime.ChainHeight(),
344	})
345	reviews.Set(agentId, revs)
346
347	chain.Emit("AgentReviewed",
348		"agentId", agentId,
349		"reviewer", caller.String(),
350		"rating", strconv.Itoa(rating),
351	)
352}
353
354// RemoveAgent removes an agent (admin or creator only).
355// All outstanding credits are refunded to their depositors before removal.
356func RemoveAgent(cur realm, id string) {
357	assertNotPaused()
358	caller := unsafe.PreviousRealm().Address()
359	val, exists := agents.Get(id)
360	if !exists {
361		panic("agent not found")
362	}
363	a := val.(*Agent)
364	if a.Creator != caller && caller != address(AdminAddress) {
365		panic("only creator or admin can remove")
366	}
367
368	// Refund all outstanding credits before removal.
369	// Use prefix + "\xff" as exclusive upper bound for the AVL prefix scan —
370	// any key starting with "id/" sorts strictly before "id/\xff".
371	bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
372	realmAddr := unsafe.CurrentRealm().Address()
373	prefix := id + "/"
374	var creditsToRefund []string
375	credits.Iterate(prefix, prefix+"\xff", func(key string, value interface{}) bool {
376		creditsToRefund = append(creditsToRefund, key)
377		return false
378	})
379	for _, key := range creditsToRefund {
380		cval, cok := credits.Get(key)
381		if !cok {
382			continue
383		}
384		balance := cval.(int64)
385		if balance > 0 {
386			userAddr := key[len(prefix):]
387			// STATE-BEFORE-SEND: zero balance before coin transfer
388			credits.Set(key, int64(0))
389			bnk.SendCoins(realmAddr, address(userAddr),
390				chain.Coins{chain.NewCoin("ugnot", balance)})
391		}
392	}
393
394	// Pay out the creator's accrued earnings BEFORE deleting the record.
395	// UseCredit moves ugnot from credits -> earnings, so these are real funds
396	// the realm holds; removing the record without paying would strand them
397	// permanently (WithdrawEarnings would then panic "agent not found").
398	if earned := GetEarnings(id); earned > 0 {
399		earnings.Set(id, int64(0)) // STATE-BEFORE-SEND
400		bnk.SendCoins(realmAddr, a.Creator,
401			chain.Coins{chain.NewCoin("ugnot", earned)})
402	}
403
404	// Clean up orphaned state: earnings and usage entries for this agent
405	earnings.Remove(id)
406	// Clean up usage entries with this agent prefix
407	usagePrefix := id + "/"
408	var usageToRemove []string
409	usage.Iterate(usagePrefix, usagePrefix+"\xff", func(k string, _ interface{}) bool {
410		usageToRemove = append(usageToRemove, k)
411		return false
412	})
413	for _, k := range usageToRemove {
414		usage.Remove(k)
415	}
416	// Also clean up any zero-balance residual credit entries
417	var creditsToRemove []string
418	credits.Iterate(usagePrefix, usagePrefix+"\xff", func(k string, _ interface{}) bool {
419		creditsToRemove = append(creditsToRemove, k)
420		return false
421	})
422	for _, k := range creditsToRemove {
423		credits.Remove(k)
424	}
425
426	agents.Remove(id)
427	reviews.Remove(id)
428
429	chain.Emit("AgentRemoved",
430		"id", id,
431		"remover", caller.String(),
432	)
433}
434
435// ── Pay-Per-Use Credit System ────────────────────────────────
436
437// DepositCredits deposits GNOT as prepaid credits for an agent.
438// Send ugnot with the transaction to fund the credits.
439func DepositCredits(cur realm, agentId string) {
440	assertNotPaused()
441	caller := unsafe.PreviousRealm().Address()
442	if _, exists := agents.Get(agentId); !exists {
443		panic("agent not found")
444	}
445
446	sent := unsafe.OriginSend()
447	amount := int64(0)
448	for _, coin := range sent {
449		if coin.Denom == "ugnot" {
450			amount += coin.Amount
451		}
452	}
453	if amount == 0 {
454		panic("must send ugnot to deposit credits")
455	}
456
457	key := agentId + "/" + caller.String()
458	existing := int64(0)
459	alreadyDepositor := false
460	if val, ok := credits.Get(key); ok {
461		existing = val.(int64)
462		alreadyDepositor = true
463	}
464	// B4: bound the distinct depositor set so RemoveAgent's refund loop stays within
465	// the gas budget. Existing depositors may always top up; only NEW depositors are
466	// capped.
467	if !alreadyDepositor && countDepositors(agentId) >= MaxDepositorsPerAgent {
468		panic("agent depositor limit reached")
469	}
470	credits.Set(key, existing+amount)
471
472	chain.Emit("CreditsDeposited",
473		"agentId", agentId,
474		"user", caller.String(),
475		"amount", strconv.FormatInt(amount, 10),
476	)
477}
478
479// UseCredit deducts one invocation credit. Only the agent creator or admin can call.
480// Returns the remaining credits.
481func UseCredit(cur realm, agentId, userAddr string) int64 {
482	assertNotPaused()
483	caller := unsafe.PreviousRealm().Address()
484	val, exists := agents.Get(agentId)
485	if !exists {
486		panic("agent not found")
487	}
488	a := val.(*Agent)
489
490	// Only the agent creator (who runs the MCP backend) or platform admin
491	// can deduct credits on behalf of users
492	if a.Creator != caller && caller != address(AdminAddress) {
493		panic("only agent creator or admin can deduct credits")
494	}
495
496	key := agentId + "/" + userAddr
497	if a.Pricing == "pay-per-use" && a.PricePerCall > 0 {
498		cval, cexists := credits.Get(key)
499		if !cexists {
500			panic("no credits deposited")
501		}
502		balance := cval.(int64)
503		if balance < a.PricePerCall {
504			panic(ufmt.Sprintf("insufficient credits: %d < %d", balance, a.PricePerCall))
505		}
506		credits.Set(key, balance-a.PricePerCall)
507
508		// Track earnings for creator withdrawal
509		earned := int64(0)
510		if ev, eok := earnings.Get(agentId); eok {
511			earned = ev.(int64)
512		}
513		earnings.Set(agentId, earned+a.PricePerCall)
514	}
515
516	// Track usage
517	a.TotalCalls++
518	agents.Set(agentId, a)
519
520	uval := int64(0)
521	if uv, uok := usage.Get(key); uok {
522		uval = uv.(int64)
523	}
524	usage.Set(key, uval+1)
525
526	remaining := int64(0)
527	if rv, rok := credits.Get(key); rok {
528		remaining = rv.(int64)
529	}
530
531	// B6: every state transition emits a typed event for indexers/agents.
532	chain.Emit("CreditUsed",
533		"agentId", agentId,
534		"user", userAddr,
535		"caller", caller.String(),
536		"remaining", strconv.FormatInt(remaining, 10),
537	)
538	return remaining
539}
540
541// GetCredits returns the credit balance for a user on an agent.
542func GetCredits(agentId, userAddr string) int64 {
543	key := agentId + "/" + userAddr
544	if val, ok := credits.Get(key); ok {
545		return val.(int64)
546	}
547	return 0
548}
549
550// GetUsage returns the total invocation count for a user on an agent.
551func GetUsage(agentId, userAddr string) int64 {
552	key := agentId + "/" + userAddr
553	if val, ok := usage.Get(key); ok {
554		return val.(int64)
555	}
556	return 0
557}
558
559// GetEarnings returns accumulated earnings for an agent.
560func GetEarnings(agentId string) int64 {
561	if val, ok := earnings.Get(agentId); ok {
562		return val.(int64)
563	}
564	return 0
565}
566
567// WithdrawEarnings allows the agent creator to withdraw accumulated usage fees.
568// Earnings accumulate when UseCredit deducts from user credit balances.
569func WithdrawEarnings(cur realm, agentId string) {
570	assertNotPaused()
571	caller := unsafe.PreviousRealm().Address()
572	val, exists := agents.Get(agentId)
573	if !exists {
574		panic("agent not found")
575	}
576	a := val.(*Agent)
577	if a.Creator != caller {
578		panic("only agent creator can withdraw earnings")
579	}
580
581	earned := int64(0)
582	if ev, eok := earnings.Get(agentId); eok {
583		earned = ev.(int64)
584	}
585	if earned == 0 {
586		panic("no earnings to withdraw")
587	}
588
589	// STATE-BEFORE-SEND: zero earnings before coin transfer
590	earnings.Set(agentId, int64(0))
591
592	bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
593	bnk.SendCoins(
594		unsafe.CurrentRealm().Address(),
595		caller,
596		chain.Coins{chain.NewCoin("ugnot", earned)},
597	)
598
599	chain.Emit("EarningsWithdrawn",
600		"agentId", agentId,
601		"creator", caller.String(),
602		"amount", strconv.FormatInt(earned, 10),
603	)
604}
605
606// RefundCredits refunds remaining credits to the caller.
607func RefundCredits(cur realm, agentId string) {
608	caller := unsafe.PreviousRealm().Address()
609	key := agentId + "/" + caller.String()
610
611	cval, cexists := credits.Get(key)
612	if !cexists {
613		panic("no credits to refund")
614	}
615	balance := cval.(int64)
616	if balance == 0 {
617		panic("zero balance")
618	}
619
620	// STATE-BEFORE-SEND: fully REMOVE the entry (not just zero it) so that the
621	// pricing lock in UpdateAgent doesn't remain triggered after a full refund.
622	credits.Remove(key)
623
624	bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
625	bnk.SendCoins(
626		unsafe.CurrentRealm().Address(),
627		caller,
628		chain.Coins{chain.NewCoin("ugnot", balance)},
629	)
630
631	chain.Emit("CreditsRefunded",
632		"agentId", agentId,
633		"user", caller.String(),
634		"amount", strconv.FormatInt(balance, 10),
635	)
636}
637
638// ── Render ───────────────────────────────────────────────────
639
640func Render(path string) string {
641	if path == "" {
642		return renderHome()
643	}
644	if path == "stats" {
645		return renderStats()
646	}
647	if strings.HasPrefix(path, "agent/") {
648		agentId := strings.TrimPrefix(path, "agent/")
649		return renderAgent(agentId)
650	}
651	return "# 404\nNot found: " + path
652}
653
654func renderHome() string {
655	var sb strings.Builder
656	sb.WriteString("# Memba Agent Registry\n\n")
657	sb.WriteString("On-chain AI Agent Marketplace for the Gno ecosystem.\n\n")
658
659	if agents.Size() == 0 {
660		sb.WriteString("*No agents registered yet.*\n")
661		return sb.String()
662	}
663
664	sb.WriteString("## Agents\n\n")
665	sb.WriteString("| ID | Name | Category | Rating | Pricing |\n")
666	sb.WriteString("| --- | --- | --- | --- | --- |\n")
667
668	agents.Iterate("", "", func(key string, value interface{}) bool {
669		a := value.(*Agent)
670		rating := "unrated"
671		if a.RatingCount > 0 {
672			rating = formatRating(a.RatingSum, a.RatingCount)
673		}
674		pricing := a.Pricing
675		if a.Pricing == "pay-per-use" && a.PricePerCall > 0 {
676			pricing = ufmt.Sprintf("pay-per-use (%d ugnot)", a.PricePerCall)
677		}
678		sb.WriteString(ufmt.Sprintf("| %s | [%s](:agent/%s) | %s | %s | %s |\n",
679			a.ID, sanitizeForRender(a.Name), a.ID, sanitizeForRender(a.Category), rating, pricing))
680		return false
681	})
682
683	return sb.String()
684}
685
686func renderAgent(id string) string {
687	val, exists := agents.Get(id)
688	if !exists {
689		return "# 404\nAgent not found: " + id
690	}
691	a := val.(*Agent)
692
693	var sb strings.Builder
694	sb.WriteString("# " + sanitizeForRender(a.Name) + "\n\n")
695	sb.WriteString(sanitizeForRender(a.Description) + "\n\n")
696	sb.WriteString("**ID:** " + a.ID + "\n")
697	sb.WriteString("**Category:** " + a.Category + "\n")
698	sb.WriteString("**Creator:** " + a.Creator.String() + "\n")
699	sb.WriteString("**Endpoint:** " + a.Endpoint + "\n")
700	sb.WriteString("**Transport:** " + a.Transport + "\n")
701	sb.WriteString("**Pricing:** " + a.Pricing + "\n")
702	if a.PricePerCall > 0 {
703		sb.WriteString("**Price:** " + strconv.FormatInt(a.PricePerCall, 10) + " ugnot/call\n")
704	}
705	sb.WriteString("**Version:** " + a.Version + "\n")
706	sb.WriteString("**Total Calls:** " + strconv.FormatInt(a.TotalCalls, 10) + "\n")
707	sb.WriteString("**Registered:** block " + strconv.FormatInt(a.BlockH, 10) + "\n")
708
709	if a.RatingCount > 0 {
710		sb.WriteString("**Rating:** " + formatRating(a.RatingSum, a.RatingCount) +
711			" (" + strconv.FormatInt(a.RatingCount, 10) + " reviews)\n")
712	}
713
714	// Capabilities
715	sb.WriteString("\n## Capabilities\n\n")
716	for _, cap := range strings.Split(a.Capabilities, ",") {
717		cap = strings.TrimSpace(cap)
718		if len(cap) > 0 {
719			sb.WriteString("- " + cap + "\n")
720		}
721	}
722
723	// Reviews
724	rval, rexists := reviews.Get(id)
725	if rexists {
726		revs := rval.([]*Review)
727		if len(revs) > 0 {
728			sb.WriteString("\n## Reviews\n\n")
729			for _, r := range revs {
730				stars := strings.Repeat("*", r.Rating) + strings.Repeat(".", 5-r.Rating)
731				sb.WriteString(ufmt.Sprintf("**%s** [%s] (block %d)\n\n",
732					truncAddr(r.Reviewer), stars, r.BlockH))
733				if len(r.Comment) > 0 {
734					sb.WriteString(r.Comment + "\n\n")
735				}
736				sb.WriteString("---\n\n")
737			}
738		}
739	}
740
741	return sb.String()
742}
743
744func renderStats() string {
745	var sb strings.Builder
746	sb.WriteString("# Registry Stats\n\n")
747
748	totalAgents := agents.Size()
749	totalReviews := int64(0)
750	totalCalls := int64(0)
751
752	agents.Iterate("", "", func(key string, value interface{}) bool {
753		a := value.(*Agent)
754		totalReviews += a.RatingCount
755		totalCalls += a.TotalCalls
756		return false
757	})
758
759	sb.WriteString(ufmt.Sprintf("**Total Agents:** %d\n", totalAgents))
760	sb.WriteString(ufmt.Sprintf("**Total Reviews:** %d\n", totalReviews))
761	sb.WriteString(ufmt.Sprintf("**Total Calls:** %d\n", totalCalls))
762
763	return sb.String()
764}
765
766// ── Helpers ──────────────────────────────────────────────────
767
768// formatRating formats a rating as "X.Y" using integer math (no float in ufmt).
769func formatRating(sum, count int64) string {
770	if count == 0 {
771		return "0.0"
772	}
773	whole := sum / count
774	// One decimal place: (sum * 10 / count) % 10
775	frac := ((sum * 10) / count) % 10
776	return strconv.FormatInt(whole, 10) + "." + strconv.FormatInt(frac, 10)
777}
778
779func truncAddr(addr address) string {
780	s := addr.String()
781	if len(s) > 13 {
782		return s[:10] + "..."
783	}
784	return s
785}
786
787func isValidTransport(t string) bool {
788	return t == "stdio" || t == "sse" || t == "streamable-http"
789}
790
791func isValidPricing(p string) bool {
792	return p == "free" || p == "pay-per-use" || p == "subscription"
793}
794
795// sanitizeForRender strips markdown-sensitive characters from user-controlled strings
796// before rendering in gnoweb to prevent injection attacks.
797func sanitizeForRender(s string) string {
798	var out strings.Builder
799	for _, c := range s {
800		switch c {
801		case '[', ']', '(', ')', '#', '*', '`', '!', '<', '>', '|', '\\', '_', '~', '\n', '\r', '\t':
802			continue
803		default:
804			out.WriteRune(c)
805		}
806	}
807	return out.String()
808}
809
810// countAgentsByCreator counts how many agents `creator` currently owns (B4 cap).
811// Bounded by MaxAgents (global), so O(MaxAgents) worst case.
812func countAgentsByCreator(creator address) int {
813	n := 0
814	agents.Iterate("", "", func(_ string, value interface{}) bool {
815		if value.(*Agent).Creator == creator {
816			n++
817		}
818		return false
819	})
820	return n
821}
822
823// countDepositors counts the distinct credit entries for an agent (B4 cap).
824// Bounded by MaxDepositorsPerAgent in steady state (the cap enforces its own
825// bound), so the scan stays cheap.
826func countDepositors(agentId string) int {
827	prefix := agentId + "/"
828	n := 0
829	credits.Iterate(prefix, prefix+"\xff", func(_ string, _ interface{}) bool {
830		n++
831		return false
832	})
833	return n
834}
835
836// hasAnyCreditsEntry returns true if ANY credit entry exists for the agent,
837// even with zero balance. This prevents a price-lock bypass where the creator
838// drains a user's balance to 0, then raises the price, then the user tops up.
839// Zero-balance entries remain as markers until explicitly cleaned via
840// RefundCredits (which removes the entry) or RemoveAgent.
841func hasAnyCreditsEntry(agentId string) bool {
842	prefix := agentId + "/"
843	found := false
844	credits.Iterate(prefix, prefix+"\xff", func(key string, value interface{}) bool {
845		found = true
846		return true // stop iteration
847	})
848	return found
849}
850
851// isValidAgentID ensures the ID contains only safe characters (no "/" or other delimiters
852// that would cause key collisions in the credits/usage AVL trees).
853func isValidAgentID(id string) bool {
854	for _, c := range id {
855		if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
856			return false
857		}
858	}
859	return true
860}