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

zones.gno

25.97 Kb · 594 lines
  1// untrusted-render: slugs, chain ids, URLs and peer addresses are charset-checked
  2// at write time by p/moul/zones; titles, descriptions, labels and reasons are a
  3// caller's free text and go through ui.Inline or ui.Cell at every call site.
  4
  5// Package zones is a curated registry of gno.land networks: mainnet, the
  6// testnets, staging chains, anybody's gnodev. Each zone carries what a node or a
  7// wallet needs to join it (chain id, RPC, gnoweb, genesis) plus a growing list of
  8// endpoints: RPCs, seeds and peers, indexers, faucets, explorers.
  9//
 10// Curation is the point. Anybody may propose a zone and register endpoints on
 11// an approved one (on a pending one, its proposer or a curator; a zone's own
 12// main RPC and gnoweb, only them); a curator approves or rejects a proposal,
 13// retires a zone that stopped running, and verifies or flags an endpoint. The
 14// latest decision on each is recorded with who made it and when, and a rejection, a retirement, a flag or an edit to an
 15// approved zone must also say why, on the zone's page. Nothing is hidden while
 16// it waits: proposals and unverified endpoints are listed, and labelled.
 17//
 18// This realm only manages the information and answers questions about it. The
 19// read helpers (GetZone, ListZones, GetEndpoint, ListEndpoints, ListAddresses,
 20// IsCurator, Curators, IsInvited, Invited) take plain values so they work from
 21// `gnokey query vm/qeval` as well as from another realm, and turning them into a
 22// node's config.toml is a separate realm's job.
 23//
 24// The model, its validation and its state machine are p/moul/zones. This realm
 25// owns only who may write.
 26package zones
 27
 28import (
 29	"chain/runtime"
 30	"strconv"
 31
 32	"gno.land/p/moul/addrset/v1"
 33	"gno.land/p/moul/zones/v0"
 34)
 35
 36// Admin is the first curator. More are invited with AddCurator and become
 37// curators when they accept.
 38const Admin address = "g1manfred47kzduec920z88wfr64ylksmdcedlf5"
 39
 40// ReviewWindow is how many blocks must pass before the author of a change may
 41// change it again, a rate bound rather than a review deadline (100 blocks is
 42// minutes): after a zone was proposed or last edited (by anybody) before its
 43// proposer may edit or withdraw it, and after an endpoint was registered before
 44// its registrant may withdraw it. Every edit bumps the revision a curator's
 45// decision must name, and a withdrawal plus a fresh proposal or registration
 46// does the same with a new revision or id, so without a wait a proposer or a
 47// registrant acting every block would keep their entry out of every curator's
 48// reach. Curators are not limited.
 49const ReviewWindow = 100
 50
 51// MaxCurators bounds curators and open invitations together. The curator list
 52// renders on the index page, and only curators can grow it, but a bound costs
 53// nothing and an unbounded list on a public page is a page someone can break.
 54const MaxCurators = 16
 55
 56var (
 57	reg      = zones.NewRegistry()
 58	curators addrset.Set
 59	invited  addrset.Set             // invited by a curator, not yet accepted
 60	inviter  = map[address]address{} // invitee -> the curator who invited them; lookups only
 61)
 62
 63func init() {
 64	curators.Add(Admin)
 65	seed()
 66}
 67
 68// ---- proposing and curating zones
 69
 70// ProposeZone files a new zone for review. Anybody may; it is listed as a
 71// proposal until a curator approves or rejects it. The review queue's caps
 72// (zones.MaxPending, zones.MaxPendingPerProposer) do not stop a curator, who
 73// also has the registry's last zones.ReservedForReviewers places, so neither a
 74// flood nor a full registry locks out the people who clear it.
 75//
 76// kind is mainnet, testnet, devnet or local. gnowebURL and genesisURL may be
 77// empty; rpcURL may not, and is <scheme>://<host>[:<port>] with no path, which
 78// is what gnokey -remote takes.
 79func ProposeZone(cur realm, slug, chainID, title, description, kind, gnowebURL, rpcURL, genesisURL string) {
 80	who := caller(cur)
 81	in := info(chainID, title, description, kind, gnowebURL, rpcURL, genesisURL)
 82	if curators.Has(who) {
 83		// A flood that fills the review queue must not lock out the people
 84		// who clear it.
 85		must(reg.ProposeExempt(who, runtime.ChainHeight(), zones.TrimSpaces(slug), in))
 86		return
 87	}
 88	must(reg.Propose(who, runtime.ChainHeight(), zones.TrimSpaces(slug), in))
 89}
 90
 91// EditZone replaces a zone's Info, every field but its slug and status. A
 92// curator may edit any pending or approved zone; the proposer may edit their
 93// own while it is pending. revision is the zone's Revision the edit was written
 94// against: it fails if the zone changed in between, its content or its status.
 95// Every edit bumps the revision, so a decision prepared against the old one
 96// fails too. A proposer edits only ReviewWindow blocks after the zone was
 97// proposed or last edited, and an edit that changes nothing is refused. On a
 98// pending zone the reason must be empty; on an approved one it is required and
 99// replaces the review on record, so the page names who changed the values, and
100// why. A new chain id sends the zone's verified endpoints back to unverified.
101// Leaving the local kind drops every endpoint on a private host, and is refused
102// while one carries a curator's ruling (a verified one its registrant may
103// withdraw first); the proposer's edit only drops endpoints that are theirs and
104// that they could withdraw on their own (RemoveEndpoint), as for RemoveZone. A
105// new main RPC or gnoweb a stranger (on an approved zone, its proposer too)
106// already lists under its kind would make
107// that listing the zone's own: on a curator's edit it is dropped if nobody
108// ruled on it, and refuses the edit if a curator did; a proposer's edit
109// refuses rather than drop what is not theirs; and a flagged listing refuses
110// the edit whoever holds it. The registry validates the edit before it drops
111// or resets anything, and a refusal reverts the edit with it. A rejected or
112// retired zone cannot be edited.
113func EditZone(cur realm, slug string, revision int64, chainID, title, description, kind, gnowebURL, rpcURL, genesisURL, reason string) {
114	who := caller(cur)
115	slug = zones.TrimSpaces(slug)
116	z := mustZone(slug)
117	in := info(chainID, title, description, kind, gnowebURL, rpcURL, genesisURL)
118	if !curators.Has(who) {
119		if z.Status != zones.Pending || z.Proposer != who {
120			panic("zones: only a curator, or the proposer while it is pending, may edit " + slug)
121		}
122		assertReviewWindow(slug, z)
123		if z.Kind == zones.Local && in.Kind != zones.Local {
124			// The edit would drop these; dropping is removing, under the
125			// rules a removal by the proposer has.
126			for _, e := range reg.PrivateEndpoints(slug) {
127				if e.Registrant != who {
128					panic("zones: leaving local would drop endpoint #" + strconv.FormatInt(e.ID, 10) +
129						", which somebody else registered; a curator must remove it first")
130				}
131				assertWithdrawable(e)
132			}
133		}
134	}
135	must(reg.Edit(slug, revision, in, who, runtime.ChainHeight(), reason))
136	// The reservation RegisterEndpoint keeps holds across an edit too: a new
137	// main RPC or gnoweb already listed under its kind by a stranger would
138	// make that listing, and its verdict, the zone's own. Checked only for a
139	// URL the edit changed, after the edit, which has validated everything: a
140	// refusal here reverts the edit with it, and an edit that cannot pass never
141	// gets this far. On a curator's edit a stranger's listing nobody ruled on
142	// is dropped, so listing a zone's likely next URL cannot hold its move
143	// off; one a curator ruled on refuses the edit, and a curator removes it
144	// first. A proposer's edit drops nothing that is not theirs.
145	for _, own := range []struct {
146		kind     zones.EndpointKind
147		url, was string
148	}{{zones.RPC, in.RPCURL, z.RPCURL}, {zones.Gnoweb, in.GnowebURL, z.GnowebURL}} {
149		if own.url == "" || (own.was != "" && zones.Canonical(own.kind, own.url) == zones.Canonical(own.kind, own.was)) {
150			continue
151		}
152		e, ok := reg.EndpointByAddress(slug, own.kind, own.url)
153		if !ok {
154			continue
155		}
156		if curators.Has(e.Registrant) || (z.Status == zones.Pending && e.Registrant == z.Proposer) {
157			// Theirs to hold, but a flagged one would mark the zone's own URL
158			// the moment it becomes one.
159			if e.Status == zones.Flagged {
160				panic("zones: " + own.url + " is listed under " + string(own.kind) + " as endpoint #" + strconv.FormatInt(e.ID, 10) +
161					", flagged; a curator removes it or rules again before it becomes " + slug + "'s own")
162			}
163			continue
164		}
165		if !curators.Has(who) {
166			// A proposer's edit (a pending zone) drops nothing that is not
167			// theirs: the only other registrant there is a former curator.
168			panic("zones: " + own.url + " is listed under " + string(own.kind) + " by " + e.Registrant.String() +
169				" (endpoint #" + strconv.FormatInt(e.ID, 10) + "); a curator removes it before it becomes " + slug + "'s own")
170		}
171		if e.ReviewedBy != "" || e.Reason != "" { // a ruling, a reset's reason
172			panic("zones: " + own.url + " is listed under " + string(own.kind) + " by " + e.Registrant.String() +
173				" (endpoint #" + strconv.FormatInt(e.ID, 10) + "), with a curator's ruling; a curator removes it before it becomes " + slug + "'s own")
174		}
175		must(reg.RemoveEndpoint(e.ID, e.Revision))
176	}
177}
178
179// ApproveZone makes a zone official. revision is the zone's Revision as you
180// read it (the zone page's Approve link carries it): if the zone changed since,
181// its content or its status, the approval fails and you read it again. The
182// reason is optional.
183func ApproveZone(cur realm, slug string, revision int64, reason string) {
184	review(cur, slug, zones.Approved, revision, reason)
185}
186
187// RejectZone turns a proposal down. The reason is required, and public.
188// revision is the zone's Revision you read, as for ApproveZone.
189func RejectZone(cur realm, slug string, revision int64, reason string) {
190	review(cur, slug, zones.Rejected, revision, reason)
191}
192
193// RetireZone marks an official zone as no longer running, and sends its
194// verified endpoints back to unverified. The reason is required: it is what an
195// operator still holding the chain id will read. revision is the zone's
196// Revision you read.
197func RetireZone(cur realm, slug string, revision int64, reason string) {
198	review(cur, slug, zones.Retired, revision, reason)
199}
200
201// RemoveZone deletes a pending or rejected zone and its endpoints. revision is
202// the zone's Revision you read: a removal meant for one proposal fails on
203// another proposed again under the same slug. A curator may remove any pending
204// or rejected zone. The proposer may withdraw their own only while it is
205// pending, ReviewWindow blocks after it was proposed or last edited
206// (so withdrawing and proposing again cannot dodge the edit wait), while every
207// endpoint on it is theirs (the deposit is refunded to whoever signs the
208// removal, so removing somebody else's endpoints would collect what they paid)
209// and each one is one they could withdraw on its own (RemoveEndpoint):
210// removing the zone must not wipe a curator's flag or unverify, or skip an endpoint's
211// own wait. A rejected zone is the curators' record: only a curator removes
212// it, or newer rejections push it out, and the rejection's deposit, paid by the
213// curator, is not the proposer's to collect. A zone that was ever official is
214// never removed this way: an approved one is retired, and a retired one is kept
215// until newer retirements push it out.
216func RemoveZone(cur realm, slug string, revision int64) {
217	who := caller(cur)
218	slug = zones.TrimSpaces(slug)
219	z := mustZone(slug)
220	if !curators.Has(who) {
221		if z.Proposer != who || z.Status != zones.Pending {
222			panic("zones: only a curator, or the proposer while it is pending, may remove " + slug)
223		}
224		assertReviewWindow(slug, z)
225		// From the index counts first: nothing is read to answer it.
226		if _, total := reg.Count(slug); reg.OwnerCount(slug, who) != total {
227			panic("zones: " + slug + " carries endpoints somebody else registered; they must be removed first")
228		}
229		// Every endpoint is the proposer's: at most MaxEndpointsPerZone (a curator
230		// who proposed it may have registered past the per-address cap).
231		for _, e := range reg.Endpoints(zones.EndpointFilter{Zone: slug}) {
232			assertWithdrawable(e)
233		}
234	}
235	must(reg.RemoveZone(slug, revision))
236}
237
238// ---- registering and curating endpoints
239
240// RegisterEndpoint lists an endpoint on a zone and returns its id. On an
241// approved zone anybody may, except the zone's own main RPC under rpc and
242// gnoweb under gnoweb, which only a curator lists (or the proposer while the
243// zone is pending: every proposer right ends at approval); on a
244// pending one only the proposer or a curator, so a stranger cannot pin a
245// proposal the proposer then cannot withdraw. It shows as unverified until a
246// curator checks it. A curator also registers past the review queue's caps
247// and into the zones.ReservedForReviewers places a gated registration may
248// not take, never past zones.MaxEndpointsPerZone.
249//
250// kind is rpc, gnoweb, seed, peer, indexer, faucet or explorer. addr is a
251// URL, or <node id>@<host>:<port> for a seed or a peer. label is optional:
252// who runs it, in your words.
253func RegisterEndpoint(cur realm, slug, kind, addr, label string) int64 {
254	who := caller(cur)
255	slug = zones.TrimSpaces(slug)
256	z := mustZone(slug)
257	if z.Status == zones.Pending && z.Proposer != who && !curators.Has(who) {
258		panic("zones: " + slug + " is pending; only its proposer or a curator may register on it")
259	}
260	k, err := zones.ParseEndpointKind(kind)
261	must(err)
262	addr = zones.TrimSpaces(addr) // compared as the registry will store it
263	// The zone's own main RPC and gnoweb, listed under their own kind, carry
264	// the verdict its page shows on them, so only a curator lists them, or the
265	// proposer while the zone is pending (every proposer right ends at
266	// approval): a stranger's listing, once flagged for its label, would mark
267	// the zone's own URL.
268	if !curators.Has(who) && !(z.Status == zones.Pending && z.Proposer == who) &&
269		((k == zones.RPC && zones.Canonical(k, addr) == zones.Canonical(k, z.RPCURL)) ||
270			(k == zones.Gnoweb && z.GnowebURL != "" && zones.Canonical(k, addr) == zones.Canonical(k, z.GnowebURL))) {
271		panic("zones: that is " + slug + "'s own " + string(k) + "; only a curator, or its proposer while it is pending, lists it")
272	}
273	register := reg.Register
274	if curators.Has(who) {
275		register = reg.RegisterExempt // past the review queue, as ProposeZone
276	}
277	id, err := register(who, runtime.ChainHeight(), slug, k, addr, label)
278	must(err)
279	return id
280}
281
282// VerifyEndpoint marks an endpoint as checked against its zone. revision is
283// the endpoint's revision as read (the endpoint table's revision column), and
284// zoneRevision the zone's (the zone page shows it): the verdict fails if
285// either changed since, another curator's verdict on the endpoint, or any
286// edit or status change of the zone (what is verified is that it answers for
287// this zone's chain id). The reason is optional. Each verdict may be given
288// again with a new reason, to restate it.
289func VerifyEndpoint(cur realm, id, zoneRevision, revision int64, reason string) {
290	reviewEndpoint(cur, id, zones.Verified, zoneRevision, revision, reason)
291}
292
293// FlagEndpoint tells readers not to use an endpoint. The reason is required.
294// revision is the endpoint's, as for VerifyEndpoint; a flag is not bound to
295// the zone, so editing the zone cannot hold off a warning.
296func FlagEndpoint(cur realm, id, revision int64, reason string) {
297	reviewEndpoint(cur, id, zones.Flagged, 0, revision, reason)
298}
299
300// UnverifyEndpoint puts an endpoint back to unverified, for one that changed
301// hands or needs checking again. revision is as for FlagEndpoint.
302func UnverifyEndpoint(cur realm, id, revision int64, reason string) {
303	reviewEndpoint(cur, id, zones.Unverified, 0, revision, reason)
304}
305
306// RemoveEndpoint deletes an endpoint. revision is its revision as read: the
307// removal fails if a verdict landed since, rather than delete one nobody saw. A
308// curator may remove any. Its registrant may remove it unless a curator flagged
309// or unverified it (a flag is a warning, an unverify carries why, and removing
310// and registering it again would wipe either; a verified one the registrant may
311// take down, since listing it again starts it unverified, and so one a reset
312// sent back to unverified, since the reset says the zone changed, not it), only while its zone is pending or approved (a rejected
313// or retired zone is a record, endpoints and all), and only ReviewWindow blocks
314// after registering it (removing and registering again every block would give
315// it a new id faster than a curator could flag the old one).
316func RemoveEndpoint(cur realm, id, revision int64) {
317	who := caller(cur)
318	e, ok := reg.Endpoint(id)
319	if !ok {
320		panic("zones: no endpoint #" + strconv.FormatInt(id, 10))
321	}
322	if !curators.Has(who) {
323		if e.Registrant != who {
324			panic("zones: only a curator or its registrant may remove endpoint #" + strconv.FormatInt(id, 10))
325		}
326		if z := mustZone(e.Zone); z.Status != zones.Pending && z.Status != zones.Approved {
327			panic("zones: " + z.Slug + " is " + string(z.Status) + ", a record; only a curator may remove its endpoints")
328		}
329		assertWithdrawable(e)
330	}
331	must(reg.RemoveEndpoint(id, revision))
332}
333
334// ClearUnreviewed removes, in one call, every endpoint on a zone that is
335// zones.Endpoint.Clearable (nobody ruled on it, and no reviewer registered it
336// past the caps), and returns how many. There are never more than
337// zones.MaxUnverifiedPerZone: clearable endpoints are a part of the review
338// queue its gate holds there, and no reset makes one. It is the answer to a
339// flood that cycles: registrants who withdraw and register again each review
340// window keep the queue full, and one removal per transaction lets them refill
341// it before a curator is done. throughRevision bounds it to what the curator
342// read: an endpoint registered after it (a later Revision) is kept. The
343// deposits go to the curator who signs. At most zones.MaxEndpointsPerZone are
344// read.
345func ClearUnreviewed(cur realm, slug string, throughRevision int64) int {
346	who := caller(cur)
347	assertCurator(who)
348	slug = zones.TrimSpaces(slug)
349	mustZone(slug)
350	n := 0
351	for _, e := range reg.Endpoints(zones.EndpointFilter{Zone: slug, Status: zones.Unverified}) {
352		if e.Clearable() && e.Revision <= throughRevision {
353			must(reg.RemoveEndpoint(e.ID, e.Revision))
354			n++
355		}
356	}
357	return n
358}
359
360// assertWithdrawable refuses a non-curator's withdrawal of an endpoint a
361// curator flagged or unverified, or one registered less than ReviewWindow
362// blocks ago. Shared by RemoveEndpoint, the proposer's RemoveZone and the
363// proposer's edit off local, so none can do what another refuses.
364func assertWithdrawable(e zones.Endpoint) {
365	id := strconv.FormatInt(e.ID, 10)
366	// A verification guards nothing a withdrawal could erase: the registrant
367	// takes down a node that is going away, and listing it again starts it
368	// unverified. A flag, or an unverify a curator wrote (a reviewer or a
369	// reason), is a warning, and removing and registering it again would wipe
370	// it. A reset is not: it reaches only a verified endpoint and says the zone
371	// changed, not the endpoint, so it leaves the withdrawal a verified one had.
372	if e.Status == zones.Flagged || (e.Status == zones.Unverified && (e.ReviewedBy != "" || e.Reason != "") && !zones.IsReset(e)) {
373		panic("zones: a curator ruled on endpoint #" + id + "; only a curator may remove it now")
374	}
375	if wait := e.RegisteredAt + ReviewWindow - runtime.ChainHeight(); wait > 0 {
376		panic("zones: endpoint #" + id + " was registered at block " +
377			strconv.FormatInt(e.RegisteredAt, 10) + "; it may be withdrawn in " +
378			strconv.FormatInt(wait, 10) + " blocks, so curators can review it")
379	}
380}
381
382// ---- curators
383
384// AddCurator invites another address to curate. Only a curator may. The
385// address becomes a curator when it calls AcceptCurator itself.
386//
387// Two steps, deliberately: a curator set is the one thing a mistake here can
388// lose for good. With a one-step add, a sole curator who invites a mistyped
389// address and then steps down leaves a registry nobody controls; accepting is
390// the proof that somebody holds the key. An invitation dies with its inviter:
391// removing a curator withdraws every invitation they sent.
392func AddCurator(cur realm, addr address) {
393	who := caller(cur)
394	assertCurator(who)
395	if !zones.ValidAddress(addr) {
396		panic("zones: not a valid lowercase address: " + addr.String())
397	}
398	if curators.Has(addr) {
399		panic("zones: " + addr.String() + " is already a curator")
400	}
401	if invited.Has(addr) {
402		panic("zones: " + addr.String() + " is already invited")
403	}
404	if curators.Size()+invited.Size() >= MaxCurators {
405		panic("zones: " + strconv.Itoa(MaxCurators) + " curators and invitations already; remove one first")
406	}
407	invited.Add(addr)
408	inviter[addr] = who
409}
410
411// AcceptCurator makes the caller a curator, if a curator invited it.
412func AcceptCurator(cur realm) {
413	who := caller(cur)
414	if !invited.Has(who) {
415		panic("zones: " + who.String() + " has no curator invitation")
416	}
417	invited.Remove(who)
418	delete(inviter, who)
419	curators.Add(who)
420}
421
422// RemoveCurator revokes a curator, along with every invitation they sent, or
423// withdraws one invitation. Only a curator may, and the last curator cannot be
424// removed: a registry nobody can curate can never retire a dead zone.
425//
426// Curators are equals: any one may remove any other, the admin included. That
427// is the trust a curator set is, and it is why there are few of them.
428func RemoveCurator(cur realm, addr address) {
429	assertCurator(caller(cur))
430	if invited.Remove(addr) {
431		delete(inviter, addr)
432		return
433	}
434	if !curators.Has(addr) {
435		panic("zones: " + addr.String() + " is not a curator")
436	}
437	if curators.Size() == 1 {
438		panic("zones: " + addr.String() + " is the last curator")
439	}
440	curators.Remove(addr)
441	for _, a := range Invited() {
442		if inviter[a] == addr {
443			invited.Remove(a)
444			delete(inviter, a)
445		}
446	}
447}
448
449// ---- reads: plain arguments, so `gnokey query vm/qeval` can call them
450
451// GetZone returns the zone under slug, and whether there is one.
452func GetZone(slug string) (zones.Zone, bool) {
453	return reg.Zone(zones.TrimSpaces(slug))
454}
455
456// ListZones returns the zones with that status and kind, in the order they
457// were proposed. "" matches any; "approved" is the official list.
458func ListZones(status, kind string) []zones.Zone {
459	st, err := zones.ParseStatus(status)
460	must(err)
461	k, err := zones.ParseKind(kind)
462	must(err)
463	return reg.Zones(zones.ZoneFilter{Status: st, Kind: k})
464}
465
466// GetEndpoint returns the endpoint with that id, and whether there is one.
467func GetEndpoint(id int64) (zones.Endpoint, bool) {
468	return reg.Endpoint(id)
469}
470
471// ListEndpoints returns a zone's endpoints of that kind and verification,
472// oldest first. "" matches any kind or verdict, so ("onyx", "", "") is
473// everything on onyx and ("onyx", "peer", "verified") is what a cautious node
474// should dial. The zone is required: one zone is at most MaxEndpointsPerZone
475// rows, every zone together is a response no node should be asked for.
476func ListEndpoints(slug, kind, status string) []zones.Endpoint {
477	return reg.Endpoints(endpointFilter(slug, kind, status))
478}
479
480// ListAddresses is ListEndpoints reduced to the addresses, which is what a
481// config file wants: ListAddresses("onyx", "peer", "verified").
482func ListAddresses(slug, kind, status string) []string {
483	es := reg.Endpoints(endpointFilter(slug, kind, status))
484	out := make([]string, 0, len(es))
485	for _, e := range es {
486		out = append(out, e.Address)
487	}
488	return out
489}
490
491// IsInvited reports whether addr holds a curator invitation it has not
492// accepted yet.
493func IsInvited(addr address) bool { return invited.Has(addr) }
494
495// IsCurator reports whether addr may curate.
496func IsCurator(addr address) bool { return curators.Has(addr) }
497
498// Curators returns every curator, in address order.
499func Curators() []address { return members(&curators) }
500
501// Invited returns every open curator invitation, in address order.
502func Invited() []address { return members(&invited) }
503
504func members(set *addrset.Set) []address {
505	out := make([]address, 0, set.Size())
506	set.IterateByOffset(0, set.Size(), func(a address) bool {
507		out = append(out, a)
508		return false
509	})
510	return out
511}
512
513// ---- helpers
514
515func review(cur realm, slug string, to zones.Status, revision int64, reason string) {
516	who := caller(cur)
517	assertCurator(who)
518	must(reg.ReviewZone(zones.TrimSpaces(slug), to, revision, who, runtime.ChainHeight(), reason))
519}
520
521func reviewEndpoint(cur realm, id int64, to zones.Verification, zoneRevision, revision int64, reason string) {
522	who := caller(cur)
523	assertCurator(who)
524	must(reg.ReviewEndpoint(id, to, zoneRevision, revision, who, runtime.ChainHeight(), reason))
525}
526
527func endpointFilter(slug, kind, status string) zones.EndpointFilter {
528	slug = zones.TrimSpaces(slug)
529	if slug == "" {
530		panic("zones: name a zone; ListZones lists them")
531	}
532	k, err := zones.ParseEndpointKind(kind)
533	must(err)
534	v, err := zones.ParseVerification(status)
535	must(err)
536	return zones.EndpointFilter{Zone: slug, Kind: k, Status: v}
537}
538
539func info(chainID, title, description, kind, gnowebURL, rpcURL, genesisURL string) zones.Info {
540	k, err := zones.ParseKind(kind)
541	must(err)
542	return zones.Info{
543		ChainID:     zones.TrimSpaces(chainID),
544		Title:       zones.TrimSpaces(title),
545		Description: zones.TrimSpaces(description),
546		Kind:        k,
547		GnowebURL:   zones.TrimSpaces(gnowebURL),
548		RPCURL:      zones.TrimSpaces(rpcURL),
549		GenesisURL:  zones.TrimSpaces(genesisURL),
550	}
551}
552
553// caller is the one place this realm decides who is acting: the realm token is
554// checked before it is walked, because an unchecked token is not a caller.
555func caller(cur realm) address {
556	if !cur.IsCurrent() {
557		panic("zones: spoofed realm")
558	}
559	return cur.Previous().Address()
560}
561
562// assertReviewWindow refuses a proposer's edit or withdrawal of a pending zone
563// sooner than ReviewWindow blocks after it was proposed or last edited,
564// by anybody.
565func assertReviewWindow(slug string, z zones.Zone) {
566	last := z.ProposedAt
567	if z.EditedAt > last {
568		last = z.EditedAt
569	}
570	if wait := last + ReviewWindow - runtime.ChainHeight(); wait > 0 {
571		panic("zones: " + slug + " was changed at block " + strconv.FormatInt(last, 10) +
572			"; its proposer may act on it again in " + strconv.FormatInt(wait, 10) + " blocks, so curators can review it")
573	}
574}
575
576func assertCurator(who address) {
577	if !curators.Has(who) {
578		panic("zones: " + who.String() + " is not a curator")
579	}
580}
581
582func mustZone(slug string) zones.Zone {
583	z, ok := reg.Zone(slug)
584	if !ok {
585		panic("zones: no zone " + strconv.Quote(slug))
586	}
587	return z
588}
589
590func must(err error) {
591	if err != nil {
592		panic(err.Error())
593	}
594}