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

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}