mygnoscan.gno
16.84 Kb · 481 lines
1// Package mygnoscan builds links into a mygnoscan block explorer.
2//
3// A realm knows things a reader cannot see from its Render output: which chain
4// it is on, what its address is, which block it was last written at. Turning
5// any of those into a link means knowing the explorer's route table, and a
6// route table hand-written into thirty realms is thirty places to fix when it
7// moves. This package is the one place.
8//
9// # The routes are measured, not guessed
10//
11// Every path below was read out of the explorer's own router (its single-page
12// frontend, `route()`), not inferred from the UI, on 2026-09-22. Two of them
13// are retired and deliberately absent: `?tab=graph` (folded into `deps`, still
14// redirected) and `?tab=inert` (folded into the default tab, silently dropped).
15//
16// # An unknown path renders the home page
17//
18// The explorer's router falls through to `home` rather than to a 404, so a
19// misspelled route is not visible as an error: the link works, it just goes
20// somewhere else. That is why the page names here are constants and why Page
21// is the only door to them.
22//
23// # The network is per instance, not per chain
24//
25// The explorer takes `?network=<id>`, and the ids are chosen by whoever runs
26// the instance: moul's names mainnet `mainnet`, while the upstream default
27// config names the same chain `gnoland1`. NetworkFor maps a chain-id to the
28// ids DefaultBase serves. Point Scanner at another instance and you very
29// likely also want WithNetwork.
30//
31// Omitting the parameter is not the same as asking for mainnet: the explorer
32// then blends every chain it indexes, which looks plausible and is wrong. A
33// Scanner whose network is empty emits no parameter, so New on a chain nobody
34// indexes produces a link to the blend. Set it explicitly when it matters.
35//
36// # Usage
37//
38// The whole point is a realm footer that stays right when the chain changes:
39//
40// func Render(path string) string {
41// return body + "\n\n---\n\n" + mygnoscan.Default().CurrentRealmFooter()
42// }
43//
44// and, for a realm that wants to hand out sharper links:
45//
46// s := mygnoscan.Default()
47// s.Address(someAddr) // an account
48// s.Block(runtime.ChainHeight()) // the block we are at
49// s.Realm("gno.land/r/moul/home", mygnoscan.TabSource)
50// s.RealmFunc("gno.land/r/moul/config", "Set") // straight at one function
51//
52// Nothing here reads chain state except ChainDomain, ChainID and ChainHeight,
53// and nothing here writes. Building a link is free.
54package mygnoscan
55
56import (
57 "chain/runtime"
58 "chain/runtime/unsafe"
59 "net/url"
60 "strconv"
61 "strings"
62)
63
64// DefaultBase is moul's instance, the one the rest of this repo links to.
65const DefaultBase = "https://mygnoscan.moul.p2p.team"
66
67// The realm-page tabs, as the explorer's own tabNames list has them. An
68// unknown tab name lands on the default tab rather than erroring, so these
69// exist to keep a typo out of a deployed realm.
70const (
71 TabInfo = "info" // the default; passing it emits no parameter
72 TabDocs = "docs" // exported symbols
73 TabSource = "source" // the .gno files as deployed
74 TabCalls = "calls" // transactions that called this realm
75 TabEvents = "events" // events it emitted
76 TabStorage = "storage" // what it pays to store
77 TabDefi = "defi" // balances and token positions
78 TabDeps = "deps" // imports, dependents and the graph over them
79)
80
81// The explorer's list pages. Page takes one of these.
82const (
83 PageRealms = "realms"
84 PagePackages = "packages"
85 PageContracts = "contracts"
86 PageApps = "apps"
87 PageTxs = "txs"
88 PageBlocks = "blocks"
89 PageAccounts = "accounts"
90 PageDefi = "defi"
91 PageCoins = "coins"
92 PageGRC20 = "grc20"
93 PageValidators = "validators"
94 PageGovDAO = "govdao"
95 PageProposals = "govdao/proposals"
96 PageVoters = "govdao/voters"
97 PageOptions = "govdao/options"
98 PageParams = "params"
99 PageEvents = "events"
100 PageGas = "gas"
101 PageGasRealms = "gas/realms"
102 PageGasUsers = "gas/users"
103 PageGasTxs = "gas/txs"
104 PageStorage = "storage"
105 PageAnalytics = "analytics"
106 PageDashboards = "dashboards"
107 PageSanity = "sanity"
108 PageWatch = "watch"
109)
110
111// The sub-views of the packages and accounts pages, the only two list pages
112// that restore a view from the URL.
113//
114// Every other list page writes its state into the query string (?by=, ?page=,
115// ?status=, ?window=, ?section=, ?failed=, ?storage=, ?txs=) and does NOT read
116// any of it back on load: a link carrying one of those opens the page on its
117// default view instead, silently. So there are no helpers for them here.
118// Measured against the explorer's router on 2026-09-22.
119const (
120 PackagesAll = "all" // every package
121 PackagesInert = "inert" // the parked queue: submitted, not yet approved
122 AccountsActivity = "activity" // ranked by what they have done
123 AccountsBalances = "balances" // ranked by what they hold
124)
125
126// Scanner is one explorer instance plus the network its links should open on.
127// It is a value: copying one is free and there is nothing to close.
128type Scanner struct {
129 base string
130 network string
131}
132
133// Default returns a Scanner pointed at DefaultBase, on whichever network
134// answers for the running chain.
135func Default() Scanner { return New(DefaultBase) }
136
137// New returns a Scanner pointed at base, on whichever network answers for the
138// running chain. A trailing slash on base is dropped so New("…/") and New("…")
139// build the same links.
140func New(base string) Scanner {
141 return Scanner{
142 base: strings.TrimSuffix(base, "/"),
143 network: NetworkFor(runtime.ChainID()),
144 }
145}
146
147// WithNetwork returns a copy of s whose links carry ?network=id. The empty
148// string removes the parameter, which asks the explorer for every chain at
149// once rather than for this one.
150func (s Scanner) WithNetwork(id string) Scanner {
151 s.network = id
152 return s
153}
154
155// Base returns the instance root, without a trailing slash.
156func (s Scanner) Base() string { return s.base }
157
158// Network returns the network id these links carry, empty when they carry none.
159func (s Scanner) Network() string { return s.network }
160
161// NetworkFor maps a chain-id to the network id DefaultBase serves it under, or
162// "" when that instance does not index the chain (a gnodev, a local test).
163//
164// Verified against DefaultBase's /api/networks on 2026-09-22: it serves
165// mainnet, pearl and staging. sapphire is mapped because the chain exists and
166// an instance configured for it uses that id; DefaultBase currently answers
167// for it with the blend, which is the same failure mode as an unknown chain
168// and is why the caller can override with WithNetwork.
169func NetworkFor(chainID string) string {
170 switch chainID {
171 case "gnoland-1":
172 return "mainnet"
173 case "pearl-1":
174 return "pearl"
175 case "sapphire-1":
176 return "sapphire"
177 case "staging":
178 return "staging"
179 default:
180 return ""
181 }
182}
183
184// URL is the one place a link is assembled: an absolute link to path on this
185// instance, carrying query as key/value pairs plus the network. A pair with an
186// empty key or value is dropped, so an optional parameter needs no branch at
187// the call site.
188//
189// Prefer the typed helpers below; this is the escape hatch for a route added
190// to the explorer after this package was last touched.
191func (s Scanner) URL(path string, query ...string) string {
192 if len(query)%2 != 0 {
193 panic("mygnoscan: odd number of query arguments")
194 }
195 if path == "" {
196 path = "/"
197 } else if !strings.HasPrefix(path, "/") {
198 path = "/" + path
199 }
200
201 v := url.Values{}
202 for i := 0; i < len(query); i += 2 {
203 if query[i] == "" || query[i+1] == "" {
204 continue
205 }
206 v.Set(query[i], query[i+1])
207 }
208 if s.network != "" {
209 v.Set("network", s.network)
210 }
211
212 out := s.base + path
213 if len(v) > 0 {
214 out += "?" + v.Encode()
215 }
216 return out
217}
218
219// Home links the explorer's front page on this network.
220func (s Scanner) Home() string { return s.URL("/") }
221
222// Page links one of the Page* list pages. A name that is not shaped like one
223// falls back to the front page rather than to the explorer's own
224// unknown-path-renders-home behaviour, which would look identical and mean
225// something else.
226func (s Scanner) Page(name string) string {
227 n := strings.Trim(name, "/")
228 if !safePath(n) {
229 return s.Home()
230 }
231 return s.URL("/" + n)
232}
233
234// Packages links the package list, on the PackagesAll or PackagesInert view.
235// The empty string takes the page's own default, which is PackagesAll.
236func (s Scanner) Packages(view string) string {
237 return s.URL("/"+PagePackages, "pv", view)
238}
239
240// Accounts links the account list, on the AccountsActivity or AccountsBalances
241// view. The empty string takes the page's own default, which is
242// AccountsActivity.
243func (s Scanner) Accounts(view string) string {
244 return s.URL("/"+PageAccounts, "av", view)
245}
246
247// Realm links a package or realm page, optionally opening one tab.
248//
249// pkgPath is accepted either fully qualified ("gno.land/r/moul/home") or bare
250// ("r/moul/home"): TrimDomain normalises it. Passing TabInfo, or no tab at
251// all, emits no tab parameter.
252func (s Scanner) Realm(pkgPath string, tab ...string) string {
253 p := TrimDomain(pkgPath)
254 if p == "" {
255 return s.Home()
256 }
257 return s.URL("/realm/"+p, "tab", oneTab(tab))
258}
259
260// CurrentRealm links the page of the realm that called into this package.
261//
262// It stack-walks (unsafe.CurrentRealm), so it names whichever realm was
263// current when it ran. For a plain read like this one that is the BORROWER,
264// which is the wanted answer: a realm asking for "my page" gets its own, and
265// so does one asking through a shared config realm, because a borrowed call
266// opens no realm frame. Measured in the test harness on 2026-09-22.
267//
268// Inside a CROSSING function it names that function's realm instead. That is
269// also correct and rarely what a link wants, so pass the path explicitly
270// there. Never branch on it for authorisation either way.
271func (s Scanner) CurrentRealm(tab ...string) string {
272 return s.Realm(unsafe.CurrentRealm().PkgPath(), tab...)
273}
274
275// RealmFunc links a realm's source tab scrolled to one exported function.
276func (s Scanner) RealmFunc(pkgPath, fn string) string {
277 p := TrimDomain(pkgPath)
278 if p == "" || fn == "" {
279 return s.Realm(pkgPath)
280 }
281 return s.URL("/realm/"+p, "tab", TabSource, "fn", fn)
282}
283
284// RealmFile links a realm's source tab scrolled to one line of one file.
285// A line of 0 or less links the file without an anchor.
286func (s Scanner) RealmFile(pkgPath, file string, line int) string {
287 p := TrimDomain(pkgPath)
288 if p == "" || file == "" {
289 return s.Realm(pkgPath)
290 }
291 if line <= 0 {
292 return s.URL("/realm/"+p, "tab", TabSource, "file", file)
293 }
294 return s.URL("/realm/"+p, "tab", TabSource, "file", file, "line", strconv.Itoa(line))
295}
296
297// Address links an account page: its balance, its transactions, what it deployed.
298//
299// The address is escaped rather than trusted. `address` is a string type and
300// nothing stops a realm casting a caller's input into one, so a value reaching
301// here is not necessarily bech32.
302func (s Scanner) Address(addr address) string {
303 if addr == "" {
304 return s.Page(PageAccounts)
305 }
306 return s.URL("/address/" + escapeSegment(addr.String()))
307}
308
309// Tx links one transaction. The hash is the base64 form the indexer and gnokey
310// print, which carries "+", "/" and "="; the whole segment is percent-escaped
311// so the explorer's decodeURIComponent gives it back byte for byte.
312func (s Scanner) Tx(hash string) string {
313 if hash == "" {
314 return s.Page(PageTxs)
315 }
316 return s.URL("/tx/" + escapeSegment(hash))
317}
318
319// Block links one block by height.
320func (s Scanner) Block(height int64) string {
321 if height <= 0 {
322 return s.Page(PageBlocks)
323 }
324 return s.URL("/block/" + strconv.FormatInt(height, 10))
325}
326
327// CurrentBlock links the block this call is executing in.
328func (s Scanner) CurrentBlock() string { return s.Block(runtime.ChainHeight()) }
329
330// Validator links one validator by address.
331func (s Scanner) Validator(addr address) string {
332 if addr == "" {
333 return s.Page(PageValidators)
334 }
335 return s.URL("/validator/" + escapeSegment(addr.String()))
336}
337
338// Proposal links one GovDAO proposal by id.
339func (s Scanner) Proposal(id int) string {
340 if id < 0 {
341 return s.Page(PageProposals)
342 }
343 return s.URL("/govdao/" + strconv.Itoa(id))
344}
345
346// Token links one GRC20 asset by its ledger key, which looks like
347// "gno.land/r/gnoswap/gns.GNS.0000000". That key carries both slashes and
348// dots, and neither is a route separator, so it is percent-escaped whole.
349func (s Scanner) Token(key string) string {
350 if key == "" {
351 return s.Page(PageGRC20)
352 }
353 return s.URL("/grc20/" + escapeSegment(key))
354}
355
356// TrimDomain strips the chain domain from a package path, so both
357// "gno.land/r/moul/home" and "r/moul/home" become "r/moul/home". A path that
358// is not shaped like a package path (see safePath) comes back empty, and every
359// caller here treats empty as "no such target" and falls back to a list page.
360//
361// It tries the running chain's domain first, then the literal "gno.land/",
362// because the explorer's own realm route reassembles the path by prepending
363// "gno.land/" unconditionally: on a chain whose domain is something else, a
364// bare path is still what the link must carry.
365func TrimDomain(pkgPath string) string {
366 p := strings.TrimPrefix(pkgPath, runtime.ChainDomain()+"/")
367 p = strings.TrimPrefix(p, "gno.land/")
368 p = strings.Trim(p, "/")
369 if !safePath(p) {
370 return ""
371 }
372 return p
373}
374
375// escapeSegment percent-encodes everything outside the RFC 3986 unreserved
376// set, which is exactly what JavaScript's encodeURIComponent does and
377// therefore exactly what the explorer's decodeURIComponent round-trips.
378//
379// url.PathEscape is the obvious choice and is wrong here twice: it leaves '/'
380// alone in some modes and, more importantly, it never escapes '(' or ')',
381// which are legal URL sub-delims and which close a markdown link early. Since
382// every link this package builds may end up inside one, the segment escaper
383// has to be stricter than the URL spec requires.
384func escapeSegment(s string) string {
385 const hex = "0123456789ABCDEF"
386 var b strings.Builder
387 for i := 0; i < len(s); i++ {
388 c := s[i]
389 switch {
390 case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9',
391 c == '-', c == '_', c == '.', c == '~':
392 b.WriteByte(c)
393 default:
394 b.WriteByte('%')
395 b.WriteByte(hex[c>>4])
396 b.WriteByte(hex[c&0x0f])
397 }
398 }
399 return b.String()
400}
401
402// safePath reports whether p is shaped like a package path: [A-Za-z0-9._/-],
403// and no empty segment.
404//
405// This is a rendering guard, not a validity check. Everything built here ends
406// up inside a markdown link, so a path carrying ')' would close the link early
407// and the rest would render as page text; a path carrying a space or a newline
408// breaks it differently. Percent-escaping cannot be the answer because '(' and
409// ')' are legal sub-delims in a URL path and url.PathEscape leaves them alone,
410// and escaping the separators would break the route. So the characters are
411// refused instead.
412//
413// It matters because a realm may well pass a path a caller typed (a registry,
414// a directory, a "link to my realm" form), and this package cannot tell.
415func safePath(p string) bool {
416 if p == "" {
417 return false
418 }
419 lastSlash := true // a leading slash was already trimmed, so no empty first segment
420 for i := 0; i < len(p); i++ {
421 c := p[i]
422 switch {
423 case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
424 lastSlash = false
425 case c == '.', c == '-', c == '_':
426 lastSlash = false
427 case c == '/':
428 if lastSlash {
429 return false // "" or "a//b"
430 }
431 lastSlash = true
432 default:
433 return false
434 }
435 }
436 return !lastSlash // no trailing slash left over
437}
438
439// Link renders a markdown link. Neither argument is escaped: everything this
440// package builds is a path or a constant, never text a caller typed. Passing a
441// user-supplied label through here would let it break out of the link.
442func Link(text, target string) string { return "[" + text + "](" + target + ")" }
443
444// RealmFooter is the line a realm appends under its Render output: the four
445// views of itself worth one click, separated by middots.
446//
447// Deliberately plain markdown with no leading rule, so the caller decides
448// where it sits and what separates it from the body.
449func (s Scanner) RealmFooter(pkgPath string) string {
450 p := TrimDomain(pkgPath)
451 if p == "" {
452 return ""
453 }
454 return Link("explorer", s.Realm(p)) +
455 " · " + Link("source", s.Realm(p, TabSource)) +
456 " · " + Link("calls", s.Realm(p, TabCalls)) +
457 " · " + Link("deps", s.Realm(p, TabDeps))
458}
459
460// CurrentRealmFooter is RealmFooter for the realm that called in. It carries
461// the same stack-walking caveat as CurrentRealm.
462func (s Scanner) CurrentRealmFooter() string {
463 return s.RealmFooter(unsafe.CurrentRealm().PkgPath())
464}
465
466// oneTab reads the optional tab argument. More than one is a call-site bug and
467// panics rather than silently using the first, and TabInfo is dropped because
468// it is the default the explorer lands on anyway.
469func oneTab(tab []string) string {
470 switch len(tab) {
471 case 0:
472 return ""
473 case 1:
474 if tab[0] == TabInfo {
475 return ""
476 }
477 return tab[0]
478 default:
479 panic("mygnoscan: at most one tab")
480 }
481}