Search Apps Documentation Source Content File Folder Download Copy Actions Download

agent_registry_v2.gno

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