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}