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

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}