PathrockNetwork Gno Explorer
HomeBlocksTransactionsTokensRealmsPackagesValidatorsAnalytics

PathrockNetwork Gno Explorer — an independent explorer for Gno.land Mainnet (gnoland-1), operated by PathrockNetwork. Not an official Gno.land service.

gnowebarchive RPC

gno.land/p/moul/mygnoscan/v0

Package
Open in gnoweb ↗

Overview

Kind
Pure package
Name
v0
Namespace
moul / mygnoscan
Files
4 (README)(gnomod.toml)
Exported functions
n/a — not supported for pure packages by the node (vm/qfuncs)
Module
gno.land/p/moul/mygnoscan/v0
gno
0.9

Files (4)

  • README.mdmarkdown
  • gnomod.tomltoml
  • mygnoscan.gnogno
  • mygnoscan_test.gnogno
mygnoscan.gnogno
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 chain4// it is on, what its address is, which block it was last written at. Turning5// any of those into a link means knowing the explorer's route table, and a6// route table hand-written into thirty realms is thirty places to fix when it7// moves. This package is the one place.8//9// # The routes are measured, not guessed10//11// Every path below was read out of the explorer's own router (its single-page12// frontend, `route()`), not inferred from the UI, on 2026-09-22. Two of them13// are retired and deliberately absent: `?tab=graph` (folded into `deps`, still14// redirected) and `?tab=inert` (folded into the default tab, silently dropped).15//16// # An unknown path renders the home page17//18// The explorer's router falls through to `home` rather than to a 404, so a19// misspelled route is not visible as an error: the link works, it just goes20// somewhere else. That is why the page names here are constants and why Page21// is the only door to them.22//23// # The network is per instance, not per chain24//25// The explorer takes `?network=<id>`, and the ids are chosen by whoever runs26// the instance: moul's names mainnet `mainnet`, while the upstream default27// config names the same chain `gnoland1`. NetworkFor maps a chain-id to the28// ids DefaultBase serves. Point Scanner at another instance and you very29// likely also want WithNetwork.30//31// Omitting the parameter is not the same as asking for mainnet: the explorer32// then blends every chain it indexes, which looks plausible and is wrong. A33// Scanner whose network is empty emits no parameter, so New on a chain nobody34// indexes produces a link to the blend. Set it explicitly when it matters.35//36// # Usage37//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 account48//	s.Block(runtime.ChainHeight())                   // the block we are at49//	s.Realm("gno.land/r/moul/home", mygnoscan.TabSource)50//	s.RealmFunc("gno.land/r/moul/config", "Set")     // straight at one function51//52// Nothing here reads chain state except ChainDomain, ChainID and ChainHeight,53// and nothing here writes. Building a link is free.54package mygnoscan5556import (57	"chain/runtime"58	"chain/runtime/unsafe"59	"net/url"60	"strconv"61	"strings"62)6364// DefaultBase is moul's instance, the one the rest of this repo links to.65const DefaultBase = "https://mygnoscan.moul.p2p.team"6667// The realm-page tabs, as the explorer's own tabNames list has them. An68// unknown tab name lands on the default tab rather than erroring, so these69// exist to keep a typo out of a deployed realm.70const (71	TabInfo    = "info"    // the default; passing it emits no parameter72	TabDocs    = "docs"    // exported symbols73	TabSource  = "source"  // the .gno files as deployed74	TabCalls   = "calls"   // transactions that called this realm75	TabEvents  = "events"  // events it emitted76	TabStorage = "storage" // what it pays to store77	TabDefi    = "defi"    // balances and token positions78	TabDeps    = "deps"    // imports, dependents and the graph over them79)8081// 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)110111// The sub-views of the packages and accounts pages, the only two list pages112// 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 read116// any of it back on load: a link carrying one of those opens the page on its117// 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 package121	PackagesInert    = "inert"    // the parked queue: submitted, not yet approved122	AccountsActivity = "activity" // ranked by what they have done123	AccountsBalances = "balances" // ranked by what they hold124)125126// 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    string130	network string131}132133// Default returns a Scanner pointed at DefaultBase, on whichever network134// answers for the running chain.135func Default() Scanner { return New(DefaultBase) }136137// New returns a Scanner pointed at base, on whichever network answers for the138// 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}146147// WithNetwork returns a copy of s whose links carry ?network=id. The empty148// string removes the parameter, which asks the explorer for every chain at149// once rather than for this one.150func (s Scanner) WithNetwork(id string) Scanner {151	s.network = id152	return s153}154155// Base returns the instance root, without a trailing slash.156func (s Scanner) Base() string { return s.base }157158// Network returns the network id these links carry, empty when they carry none.159func (s Scanner) Network() string { return s.network }160161// NetworkFor maps a chain-id to the network id DefaultBase serves it under, or162// "" 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 serves165// mainnet, pearl and staging. sapphire is mapped because the chain exists and166// an instance configured for it uses that id; DefaultBase currently answers167// for it with the blend, which is the same failure mode as an unknown chain168// 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}183184// URL is the one place a link is assembled: an absolute link to path on this185// instance, carrying query as key/value pairs plus the network. A pair with an186// empty key or value is dropped, so an optional parameter needs no branch at187// the call site.188//189// Prefer the typed helpers below; this is the escape hatch for a route added190// 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 = "/" + path199	}200201	v := url.Values{}202	for i := 0; i < len(query); i += 2 {203		if query[i] == "" || query[i+1] == "" {204			continue205		}206		v.Set(query[i], query[i+1])207	}208	if s.network != "" {209		v.Set("network", s.network)210	}211212	out := s.base + path213	if len(v) > 0 {214		out += "?" + v.Encode()215	}216	return out217}218219// Home links the explorer's front page on this network.220func (s Scanner) Home() string { return s.URL("/") }221222// Page links one of the Page* list pages. A name that is not shaped like one223// falls back to the front page rather than to the explorer's own224// unknown-path-renders-home behaviour, which would look identical and mean225// 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}233234// 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}239240// Accounts links the account list, on the AccountsActivity or AccountsBalances241// view. The empty string takes the page's own default, which is242// AccountsActivity.243func (s Scanner) Accounts(view string) string {244	return s.URL("/"+PageAccounts, "av", view)245}246247// 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 bare250// ("r/moul/home"): TrimDomain normalises it. Passing TabInfo, or no tab at251// 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}259260// CurrentRealm links the page of the realm that called into this package.261//262// It stack-walks (unsafe.CurrentRealm), so it names whichever realm was263// 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, and265// so does one asking through a shared config realm, because a borrowed call266// 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 is269// also correct and rarely what a link wants, so pass the path explicitly270// 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}274275// 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}283284// 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}296297// 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 and300// nothing stops a realm casting a caller's input into one, so a value reaching301// 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}308309// Tx links one transaction. The hash is the base64 form the indexer and gnokey310// print, which carries "+", "/" and "="; the whole segment is percent-escaped311// 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}318319// 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}326327// CurrentBlock links the block this call is executing in.328func (s Scanner) CurrentBlock() string { return s.Block(runtime.ChainHeight()) }329330// 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}337338// 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}345346// Token links one GRC20 asset by its ledger key, which looks like347// "gno.land/r/gnoswap/gns.GNS.0000000". That key carries both slashes and348// 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}355356// TrimDomain strips the chain domain from a package path, so both357// "gno.land/r/moul/home" and "r/moul/home" become "r/moul/home". A path that358// is not shaped like a package path (see safePath) comes back empty, and every359// 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 prepending363// "gno.land/" unconditionally: on a chain whose domain is something else, a364// 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 p373}374375// escapeSegment percent-encodes everything outside the RFC 3986 unreserved376// set, which is exactly what JavaScript's encodeURIComponent does and377// 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. Since382// every link this package builds may end up inside one, the segment escaper383// has to be stricter than the URL spec requires.384func escapeSegment(s string) string {385	const hex = "0123456789ABCDEF"386	var b strings.Builder387	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}401402// 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 ends406// up inside a markdown link, so a path carrying ')' would close the link early407// and the rest would render as page text; a path carrying a space or a newline408// breaks it differently. Percent-escaping cannot be the answer because '(' and409// ')' 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 are411// 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 false418	}419	lastSlash := true // a leading slash was already trimmed, so no empty first segment420	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 = false425		case c == '.', c == '-', c == '_':426			lastSlash = false427		case c == '/':428			if lastSlash {429				return false // "" or "a//b"430			}431			lastSlash = true432		default:433			return false434		}435	}436	return !lastSlash // no trailing slash left over437}438439// Link renders a markdown link. Neither argument is escaped: everything this440// package builds is a path or a constant, never text a caller typed. Passing a441// user-supplied label through here would let it break out of the link.442func Link(text, target string) string { return "[" + text + "](" + target + ")" }443444// RealmFooter is the line a realm appends under its Render output: the four445// views of itself worth one click, separated by middots.446//447// Deliberately plain markdown with no leading rule, so the caller decides448// 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}459460// CurrentRealmFooter is RealmFooter for the realm that called in. It carries461// the same stack-walking caveat as CurrentRealm.462func (s Scanner) CurrentRealmFooter() string {463	return s.RealmFooter(unsafe.CurrentRealm().PkgPath())464}465466// oneTab reads the optional tab argument. More than one is a call-site bug and467// panics rather than silently using the first, and TabInfo is dropped because468// 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}482

Functions

not supported for pure packages by the node (vm/qfuncs)

Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.