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}