service_registry.gno
19.32 Kb · 609 lines
1package service_registry
2
3import (
4 "chain"
5 "chain/runtime/unsafe"
6 "strconv"
7 "strings"
8 "time"
9
10 "gno.land/p/nt/markdown/sanitize/v0"
11)
12
13// Service represents a registered on-chain service.
14type Service struct {
15 Name string
16 Owner address
17 Registrant address // ORIGINAL registrant; immutable through transfers
18 PkgPath string // the realm the service lives at — what integrators resolve
19 Description string
20 ServiceType string // e.g. "token", "dex", "oracle", "dao", "nft", "bridge"
21 Metadata string // freeform key=value pairs or JSON blob
22}
23
24// Reservation holds a deregistered name for its former owner AND its
25// original registrant, and EXPIRES (re-audit 2026-09-02): an eternal
26// reservation let an attacker cycle register/deregister to lock the
27// whole namespace forever, and a hostile transferee could strand a
28// name against its original registrant permanently.
29type Reservation struct {
30 Owner address
31 Registrant address
32 Expires time.Time
33}
34
35const (
36 // MaxServices is a pure state bound, not an anti-squat defense —
37 // MaxServicesPerOwner is what makes monopolization expensive
38 // (pearl audit R1). Kept at 1000 rather than raised further so the
39 // linear scans over `names` (Deregister, ListServices, ListByType)
40 // stay bounded at a size one transaction can comfortably pay for.
41 MaxServices = 1000
42
43 // MaxServicesPerOwner caps how many names one address may hold at
44 // once. Enforced at registration and, for a handoff, at the moment
45 // the recipient CONSENTS (see AcceptOwnership).
46 MaxServicesPerOwner = 20
47
48 MaxNameLen = 64
49 MaxTypeLen = 32
50 MaxPkgPathLen = 128
51 MaxDescriptionLen = 500
52 MaxMetadataLen = 2000
53
54 pkgPathPrefix = "gno.land/"
55
56 // ReservationPeriod is how long a deregistered name stays reserved.
57 // Long enough for integrators to notice the deregistration; finite
58 // so tombstones cannot lock the namespace forever.
59 ReservationPeriod = int64(90 * 24 * 3600) // 90 days
60
61 // MaxRenderServices bounds the gnoweb table. Render is reachable by
62 // any viewer, so its cost lands on third parties rather than on
63 // whoever grew the state (pearl audit Y3). Complete data comes from
64 // the bounded queries named in the truncation notice.
65 MaxRenderServices = 25
66
67 // renderDescLen is the per-row description budget, applied to the
68 // RAW text before escaping so an escape sequence is never split.
69 renderDescLen = 60
70)
71
72var (
73 services map[string]*Service
74 names []string // insertion-ordered list for deterministic iteration
75 // retired maps a deregistered name to its time-bounded reservation.
76 retired map[string]*Reservation
77 // ownerServices is the O(1) index backing MaxServicesPerOwner. It is
78 // kept zero-free: an owner that drops to zero is deleted outright so
79 // the map cannot accumulate dead keys.
80 ownerServices map[address]int
81 // pendingOwners holds nominated-but-not-yet-accepted owners
82 // (pearl audit Y4). A name appears here only while a handoff is open.
83 pendingOwners map[string]address
84)
85
86func init() {
87 services = make(map[string]*Service)
88 names = []string{}
89 retired = make(map[string]*Reservation)
90 ownerServices = make(map[address]int)
91 pendingOwners = make(map[string]address)
92}
93
94// rejectStraySend aborts when coins are attached to a call (pearl audit
95// Y2): this realm handles no funds, holds no banker and exposes no
96// withdrawal, so an attached send would strand on the realm address
97// forever. Aborting reverts the transfer back to the sender. Fails open
98// for realm-routed calls, whose attached send lands on the intermediary
99// realm, never here.
100func rejectStraySend(cur realm) {
101 if cur.Previous().IsUserCall() && len(unsafe.OriginSend()) > 0 {
102 panic("this realm does not accept coins")
103 }
104}
105
106// ---------- quota index ----------
107
108func quotaAdd(owner address) {
109 ownerServices[owner] = ownerServices[owner] + 1
110}
111
112func quotaDrop(owner address) {
113 n := ownerServices[owner] - 1
114 if n <= 0 {
115 delete(ownerServices, owner)
116 return
117 }
118 ownerServices[owner] = n
119}
120
121func assertQuota(owner address) {
122 if ownerServices[owner] >= MaxServicesPerOwner {
123 panic("address already holds the maximum of " +
124 strconv.Itoa(MaxServicesPerOwner) + " services")
125 }
126}
127
128// ---------- validation helpers ----------
129
130func isValidName(name string) bool {
131 if name == "" || len(name) > MaxNameLen {
132 return false
133 }
134 for _, c := range name {
135 if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_') {
136 return false
137 }
138 }
139 return true
140}
141
142func validatePkgPath(pkgPath string) {
143 if pkgPath == "" {
144 panic("pkgpath must not be empty")
145 }
146 if len(pkgPath) > MaxPkgPathLen {
147 panic("pkgpath exceeds " + strconv.Itoa(MaxPkgPathLen) + " chars")
148 }
149 if !strings.HasPrefix(pkgPath, pkgPathPrefix) {
150 panic("pkgpath must start with " + pkgPathPrefix + " and name a package")
151 }
152 // structural, per-element validation (re-audit: the old charset check
153 // accepted "..", "//", trailing slashes, hyphens, and non-r/p roots —
154 // all lookalike-squat or allowlist-bypass material)
155 elems := strings.Split(pkgPath[len(pkgPathPrefix):], "/")
156 if len(elems) < 2 || (elems[0] != "r" && elems[0] != "p") {
157 panic("pkgpath must be gno.land/r/... or gno.land/p/...")
158 }
159 for _, e := range elems[1:] {
160 if e == "" {
161 panic("pkgpath contains an empty element")
162 }
163 if !(e[0] >= 'a' && e[0] <= 'z') {
164 panic("invalid pkgpath element: " + e)
165 }
166 for _, c := range e {
167 if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_') {
168 panic("invalid pkgpath element: " + e)
169 }
170 }
171 }
172}
173
174func validateFields(description, serviceType, metadata string) {
175 if description == "" {
176 panic("description must not be empty")
177 }
178 if len(description) > MaxDescriptionLen {
179 panic("description exceeds " + strconv.Itoa(MaxDescriptionLen) + " chars")
180 }
181 if serviceType == "" {
182 panic("service type must not be empty")
183 }
184 if len(serviceType) > MaxTypeLen || !isValidName(serviceType) {
185 panic("service type must be lowercase alphanumeric with underscores, max " +
186 strconv.Itoa(MaxTypeLen) + " chars")
187 }
188 if len(metadata) > MaxMetadataLen {
189 panic("metadata exceeds " + strconv.Itoa(MaxMetadataLen) + " chars")
190 }
191}
192
193// truncate shortens s to at most n runes without splitting a multibyte
194// character. Applied to RAW text, never to escaped output — truncating
195// after escaping could sever a `\x` pair and leave a dangling backslash.
196func truncate(s string, n int) string {
197 if n < 4 {
198 panic("truncate: budget too small")
199 }
200 runes := []rune(s)
201 if len(runes) <= n {
202 return s
203 }
204 return string(runes[:n-3]) + "..."
205}
206
207func mustGet(name string) *Service {
208 svc, ok := services[name]
209 if !ok {
210 panic("service not found: " + name)
211 }
212 return svc
213}
214
215// ---------- write operations ----------
216
217// RegisterService adds a new service to the registry. The caller becomes
218// the owner. Name must be unique, lowercase alphanumeric/underscores.
219// pkgPath is the realm the service lives at — the field integrators
220// resolve — and must look like a gno.land package path. A name that was
221// deregistered stays reserved for its former owner and its original
222// registrant for ReservationPeriod.
223//
224// NOTE: this realm does NOT and cannot verify that pkgPath exists or
225// that the caller controls it. See the INTEGRATOR CONTRACT on Resolve.
226func RegisterService(cur realm, name, pkgPath, description, serviceType, metadata string) {
227 rejectStraySend(cur)
228 c := cur.Previous().Address()
229
230 if !isValidName(name) {
231 panic("name must be 1-" + strconv.Itoa(MaxNameLen) +
232 " chars, lowercase alphanumeric with underscores only")
233 }
234 validatePkgPath(pkgPath)
235 validateFields(description, serviceType, metadata)
236 if _, exists := services[name]; exists {
237 panic("service already registered: " + name)
238 }
239
240 registrant := c
241 if res, wasRetired := retired[name]; wasRetired {
242 if time.Now().After(res.Expires) {
243 // O(1) lazy reclaim: a lapsed tombstone is dropped the moment
244 // it is consulted, so the map does not pay to keep entries
245 // nobody will ever read again.
246 delete(retired, name)
247 } else {
248 if c != res.Owner && c != res.Registrant {
249 panic("name is reserved for its former owner: " + name)
250 }
251 // round-3 audit: an in-window re-register must PRESERVE the
252 // original registrant — otherwise a hostile transferee could
253 // deregister and instantly re-register, stamping themselves as
254 // registrant and erasing the original project's reclaim right
255 if res.Registrant != "" {
256 registrant = res.Registrant
257 }
258 }
259 }
260
261 if len(services) >= MaxServices {
262 panic("global service limit reached")
263 }
264 assertQuota(c)
265
266 services[name] = &Service{
267 Name: name,
268 Owner: c,
269 Registrant: registrant,
270 PkgPath: pkgPath,
271 Description: description,
272 ServiceType: serviceType,
273 Metadata: metadata,
274 }
275 names = append(names, name)
276 quotaAdd(c)
277 delete(retired, name)
278
279 chain.Emit("ServiceRegistered",
280 "name", name,
281 "pkgpath", pkgPath,
282 "type", serviceType,
283 "owner", c.String(),
284 "registrant", registrant.String(),
285 )
286}
287
288// UpdateService modifies a service's pkgpath, description, type, and
289// metadata. Only the registered owner can update. Name cannot change.
290//
291// An update MAY REPOINT the name at a different package path. That is a
292// deliberate capability (services move, and versions supersede), but it
293// also means a name an integrator trusts today can point elsewhere
294// tomorrow. The ServiceUpdated event carries both the old and the new
295// path specifically so a repoint is observable in the transaction log
296// rather than something a consumer has to poll for.
297func UpdateService(cur realm, name, pkgPath, description, serviceType, metadata string) {
298 rejectStraySend(cur)
299 svc := mustGet(name)
300 if cur.Previous().Address() != svc.Owner {
301 panic("only the owner can update this service")
302 }
303 validatePkgPath(pkgPath)
304 validateFields(description, serviceType, metadata)
305
306 oldPath := svc.PkgPath
307 svc.PkgPath = pkgPath
308 svc.Description = description
309 svc.ServiceType = serviceType
310 svc.Metadata = metadata
311
312 chain.Emit("ServiceUpdated",
313 "name", name,
314 "oldpkgpath", oldPath,
315 "pkgpath", pkgPath,
316 "type", serviceType,
317 "owner", svc.Owner.String(),
318 )
319}
320
321// TransferOwnership NOMINATES a new owner for a service entry; the
322// nominee must call AcceptOwnership to take control (pearl audit Y4).
323//
324// The one-step form this replaces was a permanent-brick hazard:
325// address.IsValid() only checks bech32 form, so a well-formed but
326// unowned destination passed the check and committed immediately, after
327// which the entry could never again be updated, transferred or
328// deregistered — and because it could never be deregistered it could
329// never enter the reservation window either, so the NAME became a
330// permanent hole in a shared global namespace.
331//
332// Nomination changes nothing: the sitting owner keeps full control
333// until the nominee consents. Passing "" clears a pending nomination.
334func TransferOwnership(cur realm, name string, newOwner address) {
335 rejectStraySend(cur)
336 svc := mustGet(name)
337 c := cur.Previous().Address()
338 if c != svc.Owner {
339 panic("only the owner can transfer ownership")
340 }
341 if newOwner == "" {
342 delete(pendingOwners, name)
343 chain.Emit("OwnershipTransferCancelled", "name", name, "owner", c.String())
344 return
345 }
346 if !newOwner.IsValid() {
347 panic("invalid new owner address: " + string(newOwner))
348 }
349 if newOwner == svc.Owner {
350 panic("new owner is already the owner")
351 }
352 pendingOwners[name] = newOwner
353
354 chain.Emit("OwnershipTransferProposed",
355 "name", name,
356 "owner", c.String(),
357 "pending", newOwner.String(),
358 )
359}
360
361// AcceptOwnership completes a nominated handoff. Only the nominee can
362// accept, and the nominee's own quota is checked HERE — at consent —
363// so a nomination can never push an account past MaxServicesPerOwner
364// without that account agreeing to it.
365func AcceptOwnership(cur realm, name string) {
366 rejectStraySend(cur)
367 svc := mustGet(name)
368 c := cur.Previous().Address()
369
370 pending, ok := pendingOwners[name]
371 if !ok {
372 panic("no pending ownership transfer for: " + name)
373 }
374 if c != pending {
375 panic("only the nominated owner can accept")
376 }
377 assertQuota(c)
378
379 prev := svc.Owner
380 svc.Owner = c
381 quotaDrop(prev)
382 quotaAdd(c)
383 delete(pendingOwners, name)
384
385 chain.Emit("OwnershipTransferred",
386 "name", name,
387 "from", prev.String(),
388 "to", c.String(),
389 )
390}
391
392// CancelOwnershipTransfer withdraws a pending nomination. Owner-only.
393func CancelOwnershipTransfer(cur realm, name string) {
394 rejectStraySend(cur)
395 svc := mustGet(name)
396 c := cur.Previous().Address()
397 if c != svc.Owner {
398 panic("only the owner can cancel a transfer")
399 }
400 if _, ok := pendingOwners[name]; !ok {
401 panic("no pending ownership transfer for: " + name)
402 }
403 delete(pendingOwners, name)
404
405 chain.Emit("OwnershipTransferCancelled", "name", name, "owner", c.String())
406}
407
408// Deregister removes a service from the registry. Only the owner can
409// deregister. The name stays reserved for the former owner and the
410// original registrant for ReservationPeriod — it cannot be
411// re-registered by anyone else in that window, so integrators who still
412// resolve it can never be silently redirected by a squatter.
413func Deregister(cur realm, name string) {
414 rejectStraySend(cur)
415 svc := mustGet(name)
416 c := cur.Previous().Address()
417 if c != svc.Owner {
418 panic("only the owner can deregister")
419 }
420 retired[name] = &Reservation{
421 Owner: svc.Owner,
422 Registrant: svc.Registrant,
423 Expires: time.Now().Add(time.Duration(ReservationPeriod) * time.Second),
424 }
425 delete(services, name)
426 quotaDrop(svc.Owner)
427 // a nomination must not outlive the entry it was made against
428 delete(pendingOwners, name)
429
430 // Remove from ordered list
431 for i, n := range names {
432 if n == name {
433 names = append(names[:i], names[i+1:]...)
434 break
435 }
436 }
437
438 chain.Emit("ServiceDeregistered",
439 "name", name,
440 "owner", svc.Owner.String(),
441 "registrant", svc.Registrant.String(),
442 )
443}
444
445// ---------- read-only queries ----------
446
447// Resolve returns the pkgpath a service name points to — the primary
448// integration query. Panics on unknown names so a consumer can never
449// silently integrate against a missing entry.
450//
451// INTEGRATOR CONTRACT — read this before trusting a resolution:
452//
453// 1. A resolution is an ATTESTATION, NOT A PROOF. This realm records
454// that some address claimed a name for some package path. It does
455// NOT verify that the path exists, that it is deployed, or that the
456// registrant controls it. Contrast r/demo/defi/grc20reg, which
457// proves control by requiring the registered token object to
458// originate from the calling realm; no equivalent proof exists for
459// a bare path string, and requiring one would mean only realms —
460// never their operators — could ever register a name, which is not
461// this registry's model.
462//
463// 2. A NAME IS NOT AN AUTHORIZATION. Never grant a privilege, route a
464// payment, or admit a caller because Resolve returned its path.
465// Resolution answers "where does this name point", never "may this
466// caller act". Derive authority from your own crossing entrypoint's
467// cur.Previous(), or from an explicit access-control realm.
468//
469// 3. THE TARGET CAN CHANGE. The owner may repoint a name at any time
470// via UpdateService, and ownership itself is transferable. Treat a
471// resolution as valid only for the transaction that read it; cache
472// it and you inherit whatever the name points at later. The
473// ServiceUpdated and OwnershipTransferred events exist so movement
474// is detectable.
475func Resolve(name string) string {
476 return mustGet(name).PkgPath
477}
478
479// TryResolve is the non-panicking variant for consumers that need to
480// degrade gracefully when a name disappears (re-audit: a panicking-only
481// read path bricks any consumer realm that calls it inline). The
482// INTEGRATOR CONTRACT documented on Resolve applies here identically.
483func TryResolve(name string) (string, bool) {
484 svc, ok := services[name]
485 if !ok {
486 return "", false
487 }
488 return svc.PkgPath, true
489}
490
491// GetOwner returns the owner address of a service.
492func GetOwner(name string) address {
493 return mustGet(name).Owner
494}
495
496// GetPendingOwner returns the nominated-but-not-yet-accepted owner of a
497// service, or "none" when no handoff is open.
498func GetPendingOwner(name string) string {
499 mustGet(name)
500 if p, ok := pendingOwners[name]; ok {
501 return p.String()
502 }
503 return "none"
504}
505
506// ServiceCount returns how many services are registered and the global
507// cap, so a caller can check headroom without pulling the whole list.
508func ServiceCount() (count, limit int) {
509 return len(services), MaxServices
510}
511
512// OwnerServiceCount returns how many services an address currently holds
513// and the per-owner cap.
514func OwnerServiceCount(owner address) (count, limit int) {
515 return ownerServices[owner], MaxServicesPerOwner
516}
517
518// GetService returns a formatted summary of a registered service. Free
519// text is escaped for a single-line markdown slot.
520func GetService(name string) string {
521 svc := mustGet(name)
522
523 var b strings.Builder
524 b.WriteString("Name: " + svc.Name + "\n")
525 b.WriteString("Owner: " + string(svc.Owner) + "\n")
526 b.WriteString("PkgPath: " + svc.PkgPath + "\n")
527 b.WriteString("Type: " + svc.ServiceType + "\n")
528 b.WriteString("Description: " + sanitize.InlineText(svc.Description) + "\n")
529 if svc.Metadata != "" {
530 b.WriteString("Metadata: " + sanitize.InlineText(svc.Metadata) + "\n")
531 }
532 return b.String()
533}
534
535// ListServices returns all registered service names as a comma-separated
536// string in registration order.
537//
538// COST NOTE: this is O(MaxServices) and is deliberately NOT truncated —
539// an integrator enumerating the registry needs the complete set, and the
540// caller pays for its own read. Render, whose cost lands on third-party
541// viewers instead, IS bounded.
542func ListServices() string {
543 if len(names) == 0 {
544 return "none"
545 }
546 return strings.Join(names, ", ")
547}
548
549// ListByType returns all service names matching a given type. Same cost
550// note as ListServices.
551func ListByType(serviceType string) string {
552 var result []string
553 for _, name := range names {
554 svc := services[name]
555 if svc != nil && svc.ServiceType == serviceType {
556 result = append(result, name)
557 }
558 }
559 if len(result) == 0 {
560 return "none"
561 }
562 return strings.Join(result, ", ")
563}
564
565// ---------- render ----------
566
567// Render returns a markdown overview, bounded to MaxRenderServices rows
568// (pearl audit Y3). Never panics. All free text goes through the
569// ecosystem sanitizer rather than a bespoke escaper: the hand-rolled
570// replacement of backticks and pipes it replaces left `[`, `]`, `(`,
571// `)` and `!` live, so any registrant could inject a working markdown
572// link or image into a table cell and phish every viewer of this page.
573func Render(path string) string {
574 total := len(services)
575 if total == 0 {
576 return "# Service Registry\n\nNo services registered.\n"
577 }
578
579 var b strings.Builder
580 b.WriteString("# Service Registry\n\n")
581 b.WriteString("**Total services:** " + strconv.Itoa(total) + "\n\n")
582
583 b.WriteString("| Name | Type | PkgPath | Owner | Description |\n")
584 b.WriteString("|------|------|---------|-------|-------------|\n")
585
586 shown := 0
587 for _, name := range names {
588 if shown >= MaxRenderServices {
589 break
590 }
591 svc := services[name]
592 if svc == nil {
593 continue
594 }
595 desc := sanitize.TableCell(truncate(svc.Description, renderDescLen))
596 b.WriteString("| " + svc.Name + " | " + svc.ServiceType + " | `" +
597 svc.PkgPath + "` | `" + string(svc.Owner) + "` | " + desc + " |\n")
598 shown++
599 }
600
601 if shown < total {
602 b.WriteString("\n_Showing " + strconv.Itoa(shown) + " of " +
603 strconv.Itoa(total) + " services. Use ListServices, ListByType " +
604 "or GetService for complete data._\n")
605 }
606
607 b.WriteString("\n")
608 return b.String()
609}