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.

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}