Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}