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

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}