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.

holdings.gno

8.15 Kb · 236 lines
  1package nslogic
  2
  3import (
  4	"strings"
  5
  6	"gno.land/p/nt/ufmt/v0"
  7	"gno.land/r/nym-mnsrehearsal001/nsdata/v2"
  8)
  9
 10// Answering "what does this address hold" in ONE read.
 11//
 12// The site used to ask the chain for every name in the REGISTRY and then
 13// throw away the ones that did not match — one HTTP round trip per name
 14// in the world to draw one person's collection. Six names took three
 15// seconds, and it gets linearly worse for everybody every time anybody
 16// else registers anything.
 17//
 18// The scan itself is unavoidable: nsdata keys names by domain*label and
 19// has no index by owner, and nsdata is the permanent vault so no index
 20// can be added to it now. But the scan does not have to happen across
 21// the network. nslogic is swappable and can walk the same records inside
 22// a single query, which turns N round trips into one.
 23//
 24// Nothing is cached and nothing is trusted from the caller: every row is
 25// read from the vault at the moment of the query, exactly as before.
 26//
 27// Paged, because a query has a gas ceiling of its own and a registry
 28// with fifty thousand names in it must not become one enormous read that
 29// nobody can complete. `after` is the vault's own cursor, so paging here
 30// is the same paging ExportNames already does.
 31const maxHoldingsPage = 200
 32
 33// NamesOwnedBy returns rows of `label*domain,registered,expires,streak,frozen`
 34// separated by ";", plus the cursor to continue from. Labels are
 35// restricted to [a-z0-9-] so neither separator can appear inside a field.
 36//
 37// `next` is empty when the scan has reached the end of the registry. It
 38// is NOT empty merely because this page found no matches: a page of two
 39// hundred names belonging to other people returns no rows and a cursor,
 40// and the caller keeps going.
 41func NamesOwnedBy(owner address, after string, limit int) (rows, next string) {
 42	if limit <= 0 || limit > maxHoldingsPage {
 43		limit = maxHoldingsPage
 44	}
 45	keys, nxt := nsdata.ExportNames("", after, limit)
 46
 47	var b strings.Builder
 48	for _, key := range strings.Split(keys, ",") {
 49		if key == "" {
 50			continue
 51		}
 52		// The tree key is domain*label; the token ID is label*domain.
 53		at := strings.Index(key, "*")
 54		if at < 0 {
 55			continue
 56		}
 57		domainLabel, label := key[:at], key[at+1:]
 58
 59		o, registered, expires, streak, frozen, _ := nsdata.GetNameInfo(label, domainLabel)
 60		if o != owner {
 61			continue
 62		}
 63		if b.Len() > 0 {
 64			b.WriteString(";")
 65		}
 66		b.WriteString(ufmt.Sprintf("%s*%s,%d,%d,%d,%t",
 67			label, domainLabel, registered, expires, streak, frozen))
 68	}
 69	return b.String(), nxt
 70}
 71
 72// CountNamesOwnedBy is the same walk without the strings, for a header
 73// that wants a number before the list has finished arriving.
 74func CountNamesOwnedBy(owner address, after string, limit int) (n int, next string) {
 75	if limit <= 0 || limit > maxHoldingsPage {
 76		limit = maxHoldingsPage
 77	}
 78	keys, nxt := nsdata.ExportNames("", after, limit)
 79	for _, key := range strings.Split(keys, ",") {
 80		if key == "" {
 81			continue
 82		}
 83		at := strings.Index(key, "*")
 84		if at < 0 {
 85			continue
 86		}
 87		if nsdata.GetNameOwner(key[at+1:], key[:at]) == owner {
 88			n++
 89		}
 90	}
 91	return n, nxt
 92}
 93
 94// Everything the profile page draws, in ONE read.
 95//
 96// It was making seventeen queries: three to establish that the name
 97// exists and when it expires, then fourteen more — one per extension
 98// slot — that could not start until the first three had come back. Two
 99// waves, and the node serves only a few at a time, so a profile took
100// seconds to assemble out of values that all live in the same record.
101//
102// Values come back in the ORDER THE KEYS WERE ASKED FOR rather than as
103// key=value pairs. An extension key is allowed to contain "=" (only
104// commas and newlines are forbidden), so a pair encoding would have an
105// ambiguous split; positional has none.
106//
107// Line 0 is the record: owner, registered, expires, streak, frozen, and
108// the grace period, which is global but is one more query the page would
109// otherwise have to make on its own.
110//
111// An empty return means the name is not registered. That is a real
112// answer rather than an error: asking about a name nobody owns is the
113// normal case on a profile URL somebody typed.
114func ProfileOf(label, domainLabel, keys string) string {
115	if !nsdata.NameExists(label, domainLabel) {
116		return ""
117	}
118	owner, registered, expires, streak, frozen, _ := nsdata.GetNameInfo(label, domainLabel)
119
120	var b strings.Builder
121	b.WriteString(ufmt.Sprintf("%s,%d,%d,%d,%t,%d",
122		owner.String(), registered, expires, streak, frozen, nsdata.GetGracePeriod()))
123
124	for _, k := range strings.Split(keys, ",") {
125		b.WriteString("\n")
126		if k == "" {
127			continue
128		}
129		v := nsdata.GetNameExtra(label, domainLabel, k)
130
131		// THE GNO.LAND ADDRESS ANSWERS EVEN WHEN NOBODY SAVED ONE.
132		//
133		// A name is an NFT held by an address, so the owner is already
134		// public — it is the first field of the record above. Returning
135		// it here as well costs no storage and means a name can be paid
136		// without its holder ever having opened a profile editor, which
137		// is most of them, and without a transaction to back-fill the
138		// ones registered before this existed.
139		//
140		// Doing it in the realm rather than in the website is the whole
141		// point: any client asking this realm for the gno key gets the
142		// same answer our pages do. A front end that derived it locally
143		// would leave every other reader with nothing.
144		//
145		// "off" is the opt-out, and it has to be a sentinel. An empty
146		// value is indistinguishable from never having set one, so an
147		// empty gno field would simply hand the derived address back on
148		// the next read.
149		if k == "gno" {
150			if v == "off" {
151				v = ""
152			} else if v == "" {
153				v = owner.String()
154			}
155		}
156		b.WriteString(escapeValue(v))
157	}
158	return b.String()
159}
160
161// Extension VALUES are free text — a bio can hold anything a person can
162// type, newlines included — so they cannot be newline-separated raw.
163// Escaping is done here rather than stripping, because silently eating a
164// line break out of somebody's bio is data loss on a read path.
165func escapeValue(s string) string {
166	s = strings.ReplaceAll(s, `\`, `\\`)
167	s = strings.ReplaceAll(s, "\n", `\n`)
168	return strings.ReplaceAll(s, "\r", `\r`)
169}
170
171// RecentNames answers "what has just been registered" in one read.
172//
173// The site used to build this by listing every key and then reading each
174// record one at a time — the same shape NamesOwnedBy replaced for a
175// wallet's holdings, and the same problem: fine at thirteen names, one
176// query per name forever after. A feed that gets slower every time
177// somebody registers is a feed that eventually stops loading on the busy
178// day you most wanted it.
179//
180// Returns rows of "label*domain,registered,expires,owner", newest first,
181// and a cursor. Sorting happens across a PAGE, not the whole registry:
182// the vault stores names alphabetically and there is no index by date,
183// so a caller that wants the true newest reads every page and merges.
184// That is one query per 200 names instead of one per name — enough of a
185// difference to stop mattering, without pretending the vault has an
186// index it does not have.
187func RecentNames(after string, limit int) (rows, next string) {
188	if limit <= 0 || limit > maxHoldingsPage {
189		limit = maxHoldingsPage
190	}
191	keys, nxt := nsdata.ExportNames("", after, limit)
192
193	type row struct {
194		text string
195		at   int64
196	}
197	var found []row
198	for _, key := range strings.Split(keys, ",") {
199		if key == "" {
200			continue
201		}
202		// The tree key is domain*label; the token ID is label*domain.
203		at := strings.Index(key, "*")
204		if at < 1 {
205			continue
206		}
207		domainLabel, label := key[:at], key[at+1:]
208		owner, registered, expires, _, _, _ := nsdata.GetNameInfo(label, domainLabel)
209		found = append(found, row{
210			text: ufmt.Sprintf("%s*%s,%d,%d,%s", label, domainLabel, registered, expires, owner.String()),
211			at:   registered,
212		})
213	}
214
215	// Insertion sort, newest first. The page is at most 200 entries and
216	// gno has no sort in scope here; anything cleverer would be more code
217	// than the problem deserves.
218	for i := 1; i < len(found); i++ {
219		v := found[i]
220		j := i - 1
221		for j >= 0 && found[j].at < v.at {
222			found[j+1] = found[j]
223			j--
224		}
225		found[j+1] = v
226	}
227
228	var b strings.Builder
229	for _, r := range found {
230		if b.Len() > 0 {
231			b.WriteString(";")
232		}
233		b.WriteString(r.text)
234	}
235	return b.String(), nxt
236}