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}482Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.