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}