Transaction

C5517F93AC352C…47355074F46E

Block 592,216 · index 0 · indexed

Summary

Hash
C5517F93AC352C4786E261CB4A9E9767912C9200B89997A2BE7347355074F46E
Block
592,216
Size
412669 bytes
Gas used
471,252,729 / 1,164,958,800
Fee
3494876ugnot
Status
success

Messages

#1AddPackagegno.land/p/moul/zones/v013 arguments
Attached funds
78000000ugnot

Arguments · 13

  1. #1zones
  2. #2README.md
  3. #3# `gno.land/p/moul/zones/v0` **The content model of a curated registry of gno.land networks**: `Zone`, `Endpoint`, their validation, and the curation state machine, held by a `Registry`. ```go import "gno.land/p/moul/zones/v0" r := zones.NewRegistry() must(r.Propose(proposer, height, "onyx", zones.Info{ChainID: "onyx-1", Title: "Onyx", Kind: zones.Testnet, RPCURL: "https://rpc.onyx.testnets.gno.land"})) z, _ := r.Zone("onyx") must(r.ReviewZone("onyx", zones.Approved, z.Revision, curator, height, "")) z, _ = r.Zone("onyx") // the approval bumped the revision id, err := r.Register(proposer, height, "onyx", zones.Peer, "g1x5mlj5ava0dw9vkf4j6admjlzswm6f06p44krn@seed-1.onyx.testnets.gno.land:26656", "gno core") must(err) e, _ := r.Endpoint(id) // a verification names the zone's revision and the endpoint's must(r.ReviewEndpoint(id, zones.Verified, z.Revision, e.Revision, curator, height, "answers onyx-1")) r.Endpoints(zones.EndpointFilter{Zone: "onyx", Kind: zones.Peer, Status: zones.Verified}) // that one peer ``` A **zone** is one network: chain id, title, description, kind (`mainnet`, `testnet`, `devnet`, `local`), its main RPC, gnoweb and genesis URLs. An **endpoint** is one way into a zone: `rpc`, `gnoweb`, `seed`, `peer`, `indexer`, `faucet` or `explorer`, `unverified` until a curator says otherwise. The live registry is [`r/moul/zones`](../../../r/moul/zones), which holds one `Registry` and decides who may write to it. **It decides nothing about who may act, deliberately.** Every write that records a decision takes the acting address and the height as arguments (the two removals take neither: who may remove is the holder's call). Whether that address is a curator, the proposer, or nobody is the holding realm's call, so the same model can move under a DAO or a system realm later without a line changing here. ## The curation policy | decision | from | reason | also | |---|---|---|---| | approve | pending, rejected, retired; approved, to restate the reason | optional (required to restate) | | | reject | pending; rejected, to restate the reason | **required** | verified endpoints go back to unverified (a restatement resets nothing) | | retire | approved; retired, to restate the reason | **required** | verified endpoints go back to unverified (a restatement resets nothing) | | edit | pending, approved | none before review, **required** after | a new chain id un-verifies endpoints; leaving `local` drops the private ones, at most `MaxDropPerEdit` (64) in one edit, refused while one carries a curator's ruling | | remove | pending, rejected | | endpoints go with it | | verify an endpoint | any, the same one only with a new reason | optional | zone pending or approved | | unverify an endpoint | any, the same one only with a new reason | optional | | | flag an endpoint | any, the same one only with a new reason | **required** | | Every endpoint verdict, and every removal of one endpoint, names the endpoint's own `Revision`, bumped on registration, on every verdict and on every reset, from the same never-repeating counter zones use: so it fails if another curator ruled on the endpoint since it was read (a verdict landing unseen would reverse theirs). A verification also names the zone's `Revision`, and fails if the zone changed: what it checks is that the endpoint answers for this zone's chain id. A flag or an unverify does not, so editing a zone cannot hold off a warning. The two are named apart, never combined, because a reader who takes them from two reads at two heights could otherwise combine stale ones into a valid one. - **Every decision on a zone binds to the zone as read**: its fields and its status. Every edit bumps the zone's `Revision`, registry-wide and never reused (not even by a zone removed and proposed again under the same slug). Approving, rejecting, retiring, editing and removing all name it, so an edit that lands between the reading and the decision makes the decision fail, instead of attaching a name to text nobody read. Every status change bumps it too, so an approval opened before a colleague's rejection fails rather than reversing it. Verifying an endpoint binds the same way, to the revision it was checked against: what was checked is that it answers for this zone's chain id. An edit that changes nothing is refused, so a revision only moves when something did. An endpoint's verdict is not part of the zone and does not move it: a flag on the zone's own main RPC shows on the zone, it does not invalidate an approval in flight. - **Only a pending or approved zone is editable.** An edit before review takes no reason. An edit to an approved zone is a curator decision: the reason is required and replaces the review on record. A changed chain id, on any zone, sends every verified endpoint back to unverified, because what was verified was that it answered for the old one. A reset caused by an edit to a pending zone by its own proposer records nobody, because that is not a review: `ReviewedBy` is empty and `Reason` says why. Every other reset records the editor or the reviewer who caused it. - **Nothing goes back to pending.** A rejected zone is approved after all, removed, or pushed out by 64 newer rejections. - **A zone that was ever official is never removed by a caller.** An approved one is retired, and a retired one is kept until 128 newer retirements push it out, so whoever still holds its chain id can find out what happened to it. ## What a field accepts Validated at write time, not escaped at render time, for everything that is also an index key, a URL segment or a config-file line: - **Slug**: 2 to 32 of `[a-z0-9-]`, alphanumeric at both ends. - **Chain id**: 1 to 50 of `[A-Za-z0-9._-]`. - **URL**: visible ASCII with none of `` <>"'`()[]{}|\^# ``, no credentials, no `%` without two hex digits after it, no `&name;` shape, a host that is a DNS name or an IPv4 address (anything a browser would read as IPv4, like `0x7f.1`, must be a valid dotted quad), an optional port of 1 to 65535 with no leading zero, in digits, and a scheme from a short list. A zone's main RPC is `http`, `https` or `tcp` with no path, query or trailing slash, which is what `gnokey -remote` dials; an `rpc` endpoint also takes `ws`, `wss` and a path (a `tcp://` one is host and port only, since gnokey dials it as such), and an `indexer` takes `ws` and `wss` for its subscriptions. A `%` never escapes a character that needs no escaping, and a path has no `.` or `..` segment, so a URL has one spelling. A host that names a different machine for every reader is refused except on a `local` zone: a name with no dot, `.localhost`, `.local`, `.internal`, `.localdomain`, the RFC 6761 names `.test`, `.example` and `.invalid`, the never-delegated `.lan`, `.home`, `.corp`, `.mail`, `.intranet`, `.private`, `.onion`, `.alt`, the service-discovery, container and overlay names `.consul`, `.lxd`, `.incus`, `.docker`, `.podman`, `.localnet`, `.svc` and `.i2p`, every `.arpa` name (infrastructure, never a public service), and IPv4 "this network", loopback, private, link-local, CGNAT, IETF-protocol, documentation, benchmarking, 6to4 relay anycast, multicast and reserved ranges. `IsPrivateHost` fails closed: anything but a bare host is private to it. A zone that leaves the `local` kind drops every endpoint on one, in the same edit, and the edit is refused while one of them carries a curator's ruling. An IPv4 host has no terminal dot. - **Peer**: `<node id>@<host>:<port>`, the shape `p2p.persistent_peers` takes, the node id a lowercase g1 address (tm2 compares node ids byte for byte), and no terminal dot on an IPv4 host (Go's dialer cannot use one, so URLs refuse it too). - **Address** (proposer, registrant, reviewer): a valid g1 address in lowercase. bech32 also decodes the uppercase form, and here it would be a second identity. - **Trimming**: tabs and every Unicode space separator (Zs: U+0020, U+00A0, U+3000 and the like) are trimmed from what a caller types. Anything else at an edge (a line separator, U+0085) reaches the validator and is refused, not cut. A zone's gnoweb and genesis URLs, like an endpoint's, have an empty query's `?` and a bare `/` dropped, as a browser's address bar writes them; the zone's main RPC, which gnokey dials, is refused with them. An endpoint is validated as it will be stored. - **Free text** (title, description, label, reason): one line, bounded in characters (so at most four times as many bytes), valid UTF-8, with no control character, no invisible, format, private-use or unassigned character, no variation selector except right after a character it modifies (FE0E or FE0F after a symbol or one of the five emoji whose base is punctuation or a letter by category (‼ ⁉ ℹ 〰 〽), as a phone writes ❤️ or ‼️; a Mongolian free variant; an ideographic variation sequence), no enclosing mark (it draws a badge's frame around any character), no run of more than four nonspacing marks, none of the status glyphs a Render draws nor their look-alikes, and, unless it is empty, something visible. Refused rather than stripped, so what is stored is what is shown. It must still be escaped by whoever renders it. Two consequences: the zero-width joiner and non-joiner are refused, so the Persian and Sinhala spellings and the emoji sequences that need them cannot be written; and "assigned" means in the chain's own Unicode tables, 15.0, so a character assigned since is refused on chain though `gno test` (which uses the host's tables) accepts it. An endpoint, like a zone's URLs, is stored with its scheme and host lowercased (a peer lowercased whole, without its host's terminal dot; a URL without an empty query's `?` or a bare `/`, which gnokey would dial), so every later comparison finds nothing to change, and deduplicated on `Canonical`: scheme and host lowercased, a terminal dot, a default port, an empty path before a query, an empty query's `?` and a bare `/` dropped, `%XX` hex uppercased, an rpc `tcp://` read as the `http://` gnokey dials, anything meaningful after the host kept as typed. The same address under two kinds is two endpoints. ## Bounds The **live registry**, pending and approved zones together, holds at most 256. Rejected and retired zones are kept for the record but do not count against it: each state keeps at most 64 and 128, and the next one in drops the zone that has been in that state longest, endpoints and all. So no flood, no curator and no amount of time fills the registry for good. The last `ReservedForReviewers` (16) places of each hard cap, the live registry and a zone's endpoints, take only the exempt calls: strangers who keep a cap full would otherwise lock out the curators who act under it. **Per-address caps** make one address cheap to ignore: 4 pending proposals, 16 endpoints on a zone. Neither stops a flood from many addresses, so **the review queue has an admission gate of its own**: 64 pending zones in all, 64 endpoints per zone waiting for a verdict, under a hard 128 per zone. A flagged endpoint has its verdict and leaves the queue, so curators keep warnings instead of deleting them to make room. The gate is checked where something enters, so a review or a reset can push a count past it. A flood fills the queue and never crowds out an approved zone or a verified endpoint, and `ProposeExempt` and `RegisterExempt` skip the gate and the per-address caps (not the hard caps) for the reviewers the holding realm trusts, so a full queue never locks out the people who clear it. An endpoint registered that way is marked `Exempt`. `Clearable` is an endpoint nobody ruled on that is not `Exempt`, what a bulk clear of a flood may remove, and `Registry.Clearable` counts them without reading one; since the gate holds the queue, there are never more than 64. Each entry costs its sender a storage deposit, refunded to whoever signs the transaction that frees it (on a chain with transfers locked, to the storage fee collector instead), so a flooder who withdraws first gets it back: a bond, not a fee. ## Storage Records live in a B+ tree, the keyed, ordered container EFFECTIVE_GNO recommends for iteration and pagination (671 B per entry at fanout 32), with ids that are never reused. Every tree here, `kit/index`'s included, is at fanout 32, not 128: a removal shifts every later value in its leaf, and each shifted value is rewritten, about 90k gas apiece for a number and 230k for a pointer on a real node, so a smaller leaf bounds what one removal costs. `kit/store` has that shape on an avl tree (2,029 B), and on an immutable path the choice is permanent. Three B+ trees hold an id per key: slug, the endpoint dedup key (a 128-bit hash of the canonical address rather than a second copy of it), and the order zones entered their state. Four hold a count per key: pending proposals per proposer, endpoints per registrant on a zone, flagged and clearable endpoints per zone. The other six are [`kit/index`](../kit/index): status, approved-by-chain-id, and for endpoints zone, zone-and-kind, not-verified and on-a-private-host (so leaving `local` reads only those). Every one is written in the same method as its record; counts come from the indexes without reading a record, and a page reads the records on it only (the bucket's id list, at most 256 or 128 ids, is read whole). Measured on a node at the caps, with every text field at full length in the costliest characters and 240-character hosts: the heaviest page is a zone page of 25 flagged rows with full reasons, about 1.5B of the 3B query cap. A removal shifts the later records in its B+ tree leaf, and borrows from a neighbour when the leaf underflows; endpoint ids are handed out registry-wide, so what a removal of a zone's endpoints costs depends on how they interleave with other zones'. With them registered together, a rejection or retirement that evicts a full zone costs about 0.39B; in the worst layout a registrant can arrange, with the shifted neighbours at full size and flagged and the zone's verified endpoints spread across leaves, 2.35B (78% of a 3B block), the heaviest write (an earlier 2.20B layout was sent to a node capped at 3B and landed). That is also why an edit leaving `local` drops at most 64 (2.02B worst, with a chain-id change and both own URLs moved in the same edit). <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/p/moul/zones/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/p/moul/zones/v0/deps.png) > ⚠️ **Disclaimer:** provided as-is, without warranty; not security-audited. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
  4. #4gnomod.toml
  5. #5module = "gno.land/p/moul/zones/v0" gno = "0.9"
  6. #6registry.gno
  7. #7package zones import ( "crypto/sha256" "errors" "strconv" "gno.land/p/moul/kit/index/v0" "gno.land/p/moul/kit/store/v0" "gno.land/p/nt/bptree/v0" ) // Bounds on what the registry holds. // // The LIVE registry, pending and approved zones, has a hard cap. Rejected and // retired zones are kept for the record but do not count against it: each has // a cap of its own, and when it is reached the zone that has been in that state // longest is dropped to make room (by when it entered the state, not when it // was proposed, so a long-lived network retired today is not the next to go). // So nothing a proposer or a curator does, and no amount of time, can fill the // registry for good: a cap that only ever fills is a lifetime cap, and on an // immutable path that is a brick. // // The per-address caps make one address cheap to ignore. Neither stops a flood // from many addresses, because an address is not an identity, so admission to // the review queue has a gate of its own (MaxPending zones, MaxUnverifiedPerZone // endpoints awaiting a verdict), well under the hard caps: a flood fills the // queue and never crowds out an approved zone or a verified endpoint. A // flagged endpoint has its verdict and leaves the queue, so curators keep a // warning instead of having to delete it to make room. The gate is checked // where something enters (Propose, Register); a review or a reset can push a // count past it, and never fails for it. ProposeExempt and RegisterExempt skip // it, and the per-address caps, for the reviewers a holder trusts: a full // queue must not lock out the people who clear it. The queue clears through // approval, verification, a flag, rejection, RemoveZone, RemoveEndpoint, an // edit leaving the local kind, and eviction. Flagged endpoints still count // toward MaxEndpointsPerZone: a flood a curator flags rather than removes can // fill the places a gated registration may use (all but the last // ReservedForReviewers), and removing it is the answer. Each entry costs its sender a // storage deposit, refunded to whoever signs the transaction that // frees it (on a chain where ugnot is not transfer-locked; on one that is, the // refund goes to the storage fee collector), so a flooder who withdraws first // gets it back: a bond, not a fee. const ( MaxZones = 256 // pending and approved zones together MaxPending = 64 // zones awaiting review, all proposers together MaxPendingPerProposer = 4 // open proposals one address may have at once MaxRejected = 64 // rejected zones kept for the record MaxRetired = 128 // retired zones kept for the record MaxEndpointsPerZone = 128 MaxUnverifiedPerZone = 64 // endpoints on one zone still waiting for a verdict (flagged ones have theirs) MaxEndpointsPerAddress = 16 // endpoints one address may register on one zone // MaxDropPerEdit bounds how many endpoints one edit off the local kind // drops, and so its gas: a removal rewrites up to about 45 later values in // two B+ tree leaves, and a caller can lay ids out so every removal does, // which at 128 measured 2.6B, close to a 3B block, and at 64 measures // 2.02B even with a chain-id reset spread across leaves and both own URLs // moved in the same edit. More waits for removals. MaxDropPerEdit = 64 // ReservedForReviewers is how much of each hard cap (MaxZones live, // MaxEndpointsPerZone) only the exempt calls may use: strangers who keep // a cap full refill it the block after a curator clears it, and a cap a // curator cannot reach is one they cannot act under. ReservedForReviewers = 16 ) // The reasons an endpoint carries when its zone changed under it and sent it // back to unverified. A reset caused by an edit to a pending zone by its own // proposer records nobody, because that is not a review: ReviewedBy is empty // and Reason says why. Every other reset (a rejection, a retirement, anybody // else's edit, any edit to an approved zone) records whoever caused it. const ( ChainIDChanged = "the zone's chain id changed; verify again" ZoneRetired = "the zone was retired; verify again if it returns" ZoneRejected = "the zone was rejected; verify again if it is approved" ) // IsReset reports whether an unverified endpoint is unverified only because its // zone changed under it: its reason is one of the three above. A reset reaches // only a verified endpoint, so it says nothing against the endpoint itself. func IsReset(e Endpoint) bool { return e.Status == Unverified && (e.Reason == ChainIDChanged || e.Reason == ZoneRetired || e.Reason == ZoneRejected) } // sequences are the registry-wide counters: the last Revision handed out (so // none is reused) and the last status-entry sequence (the eviction order). type sequences struct{ rev, seq uint64 } // fanout is the B+ tree fanout for every container this package builds itself: // 32, not the 128 EFFECTIVE_GNO.md measured cheapest in memory. Every value in // a B+ tree leaf is a boxed object of its own, and removing (or inserting) a // key shifts every later value in the leaf, each shift a store write. Measured // on a node, one transaction per step (gno master 3cc494ec4): removing the // first key of a fanout-128 leaf filled with 120 cost 15.8M gas, the last 5.1M, // about 90k gas per entry shifted for a scalar value (the counters, the unique // ids) and about 230k for a pointer (the zone and endpoint tables, kit/index); // at fanout 32 each costs the same per shift, on a leaf a quarter the size. Endpoints are removed and their dedup keys inserted at // arbitrary positions, so the smaller leaf is worth its larger node overhead // (671 B per entry against 592). const fanout = 32 // Registry holds the zones and their endpoints. The zero value is not usable: // call [NewRegistry]. All its state is behind pointers, so a copy of the value // is the same registry, not a second one sharing half of it. // // Every write touches its record and all of that record's indexes in one // method. The index writes cannot fail as written: every key is validated // non-empty and every unique key is checked free before the first write. If a // later change made one fail, the method returns the error and the holding // realm's abort undoes the half-applied write; that abort, not this method, is // the atomicity guarantee. type Registry struct { // The two sequences live behind a pointer like every container here, so a // copied Registry value shares them: with them inline, r2 := *r1 would // share every zone but count revisions on its own, and hand out a revision // r1 had already used, which a stale decision would then pass. ids *sequences zones *table bySlug *unique // slug -> zone id byStatus *index.Index // status -> zone ids, so a page reads only its rows entered *unique // status|entry sequence -> zone id: who has been in a state longest byProposer *counter // proposer -> how many PENDING zones they have approved *index.Index // chain id -> ids of APPROVED zones naming it endpoints *table byAddress *unique // slug|kind|hash(canonical address) -> endpoint id byZone *index.Index // slug -> endpoint ids byKind *index.Index // slug|kind -> endpoint ids, so a page reads only its rows byOwner *counter // slug|registrant -> how many endpoints they registered unchecked *index.Index // slug -> ids of endpoints that are not Verified flagged *counter // slug -> how many of those are Flagged fresh *counter // slug -> how many endpoints are Clearable private *index.Index // slug -> ids of endpoints on a private host (a local zone's only) } // NewRegistry returns an empty registry. func NewRegistry() *Registry { return &Registry{ ids: &sequences{}, zones: newTable(), bySlug: newUnique(), byStatus: index.New(), entered: newUnique(), byProposer: newCounter(), approved: index.New(), endpoints: newTable(), byAddress: newUnique(), byZone: index.New(), byKind: index.New(), byOwner: newCounter(), flagged: newCounter(), fresh: newCounter(), unchecked: index.New(), private: index.New(), } } // Propose files a new zone, Pending, under a slug nobody holds. func (r *Registry) Propose(by address, at int64, slug string, in Info) error { return r.propose(by, at, slug, in, true) } // ProposeExempt is Propose without the admission gates (MaxPending and // MaxPendingPerProposer), for the reviewers a holding realm trusts: a flood // that fills the queue would otherwise lock out the people who clear it. It // also takes the last ReservedForReviewers places of the live registry, which // Propose may not; MaxZones itself still holds. func (r *Registry) ProposeExempt(by address, at int64, slug string, in Info) error { return r.propose(by, at, slug, in, false) } func (r *Registry) propose(by address, at int64, slug string, in Info, gated bool) error { in = trimInfo(in) if err := ValidateSlug(slug); err != nil { return err } if err := ValidateInfo(in); err != nil { return err } if !ValidAddress(by) { return errors.New("zones: the proposer is not a valid lowercase address") } if r.bySlug.has(slug) { return errors.New("zones: the slug " + strconv.Quote(slug) + " is taken") } if r.Live() >= MaxZones { return errors.New("zones: the registry is full at " + strconv.Itoa(MaxZones) + " pending and approved zones") } if gated && r.Live() >= MaxZones-ReservedForReviewers { return errors.New("zones: the registry's last " + strconv.Itoa(ReservedForReviewers) + " places are kept for curators") } if gated && r.byStatus.Count(string(Pending)) >= MaxPending { return errors.New("zones: " + strconv.Itoa(MaxPending) + " proposals are already waiting for review; try again once a curator has cleared some") } if n := r.byProposer.count(by.String()); gated && n >= MaxPendingPerProposer { return errors.New("zones: " + by.String() + " already has " + strconv.Itoa(n) + " pending proposals, the limit is " + strconv.Itoa(MaxPendingPerProposer)) } z := &Zone{Slug: slug, Proposer: by, ProposedAt: at} z.setInfo(in) id := r.zones.add(z) r.bySlug.set(slug, id) r.byProposer.inc(by.String()) return r.enter(z, id, Pending) } // enter files a zone under a status, and bumps its Revision: a decision // prepared against the old status must fail too, or an approval opened before // a colleague's rejection would quietly reverse it. Only the two states that // evict read the entry order, so only they write it. The caller has already // taken the zone out of its old status. func (r *Registry) enter(z *Zone, id store.ID, to Status) error { r.ids.seq++ r.ids.rev++ z.Status, z.Entered, z.Revision = to, int64(r.ids.seq), int64(r.ids.rev) if evicts(to) { r.entered.set(enteredKey(to, z.Entered), id) } return r.byStatus.Add(string(to), id) } func evicts(st Status) bool { return st == Rejected || st == Retired } // leave takes a zone out of its current status. func (r *Registry) leave(z *Zone, id store.ID) { r.byStatus.Remove(string(z.Status), id) if evicts(z.Status) { r.entered.remove(enteredKey(z.Status, z.Entered)) } if z.Status == Pending { r.byProposer.dec(z.Proposer.String()) } if z.Status == Approved { r.approved.Remove(z.ChainID, id) } } func enteredKey(st Status, seq int64) string { return string(st) + "|" + store.ID(seq).Key() } // Edit replaces a zone's Info: every field but the slug and the status. // // Only a pending or an approved zone can be edited. A rejected one is removed // and proposed again, or approved first; a retired one is a record, and its // retirement reason is the thing it is kept for. // // Every edit bumps the zone's Revision and records who made it and when. On a // pending zone that is all, and a reason is refused rather than silently // dropped: nobody has reviewed anything yet. On an approved zone an edit is a // curator decision of its own: the reviewed values changed, so the review on // record is replaced by this one, with a reason required. // // When the chain id changes, whatever the status, every endpoint verified // against the old one goes back to unverified: what was verified was that it // answered for a chain this zone no longer names. A reset caused by the // proposer's own edit to a pending zone records no reviewer; any other records // the editor. An edit that changes nothing is refused, and leaving the local // kind removes every endpoint on a private or special-use host (IsPrivateHost), // at most MaxDropPerEdit in one edit, and is refused while one of them carries // a curator's ruling. // // An edit names the revision it was written against, like an approval does, so // two editors working from the same reading cannot silently undo each other. func (r *Registry) Edit(slug string, revision int64, in Info, by address, at int64, reason string) error { z, zid, err := r.zone(slug) if err != nil { return err } if err := stale(z, revision); err != nil { return err } if z.Status != Pending && z.Status != Approved { return errors.New("zones: " + slug + " is " + string(z.Status) + "; only a pending or approved zone can be edited") } in = trimInfo(in) if err := ValidateInfo(in); err != nil { return err } if !ValidAddress(by) { return errors.New("zones: the editor is not a valid lowercase address") } reason = TrimSpaces(reason) if err := ValidateReason(reason); err != nil { return err } switch { case z.Status == Pending && reason != "": return errors.New("zones: " + slug + " is pending, and an edit before review takes no reason") case z.Status == Approved && !HasVisible(reason): return errors.New("zones: " + slug + " is approved, so an edit needs a reason") } // An edit that changes nothing would still bump the revision, so refusing // it is what stops a revision being bumped for its own sake. if in == z.Info() { return errors.New("zones: that edit changes nothing on " + slug) } // Leaving the local kind drops the private hosts only a local zone may // list, whatever their verdict: they name nothing on the zone it becomes. // Dropped in the edit itself, not refused until somebody removes them, // because anybody may register one again between that removal and this // edit. Only those endpoints are read, from their own index. One a // curator ruled on (a verdict, or a reset that left a reason) is a // record, maybe one the editor never saw: the edit fails while one is // listed, and a curator removes it first, naming its revision. Only a // curator can rule, so nobody else can block the edit this way. if z.Kind == Local && in.Kind != Local { drop := r.private.Lookup(slug) if len(drop) > MaxDropPerEdit { return errors.New("zones: leaving local would drop " + strconv.Itoa(len(drop)) + " endpoints, more than " + strconv.Itoa(MaxDropPerEdit) + " in one edit; remove some first") } for _, eid := range drop { v, _ := r.endpoints.get(eid) // Every verdict names its reviewer and a reset leaves a reason, so // these two say "ruled on". if e := v.(*Endpoint); e.ReviewedBy != "" || e.Reason != "" { who := "a curator ruled on; a curator must remove it first" if e.Status == Verified { who = "is verified; its registrant or a curator must remove it first" } return errors.New("zones: leaving local would drop endpoint #" + strconv.FormatInt(e.ID, 10) + ", which " + who) } } for _, eid := range drop { r.removeEndpoint(eid) } } if in.ChainID != z.ChainID { // The proposer is not a reviewer; anybody else editing (a curator, on a // pending zone or an approved one) is, and is named on the reset. resetBy := address("") if z.Status == Approved || by != z.Proposer { resetBy = by } if z.Status == Approved { r.approved.Remove(z.ChainID, zid) if err := r.approved.Add(in.ChainID, zid); err != nil { return err } } if err := r.unverifyAll(slug, resetBy, at, ChainIDChanged); err != nil { return err } } if z.Status == Approved { z.ReviewedBy, z.ReviewedAt, z.Reason = by, at, reason } r.ids.rev++ z.Revision = int64(r.ids.rev) z.EditedBy, z.EditedAt = by, at z.setInfo(in) return nil } // ReviewZone records a curator's decision on a zone. // // The transitions are the curation policy, and they are deliberately few: // // approve from pending, rejected or retired reason optional // reject from pending reason REQUIRED // retire from approved reason REQUIRED // // and a decision restated: the zone's present status again, with a new // visible reason, which records the new reviewer and bumps the revision and // does nothing else (no new entry in the state, no eviction, no reset). It is // the only way to correct a reason. // // Every decision names the zone's Revision the curator read, and fails if the // zone changed since: otherwise a proposer's edit landing just before an // approval would become official under the curator's name, and a rejection's // public reason would describe text the curator never saw. // // Rejecting or retiring sends every verified endpoint back to unverified: a // rejected zone was never vouched for, and a retired network's peers may be // somebody else's hosts by the time anyone reads them. Each state keeps at most // MaxRejected or MaxRetired zones; the next one in drops the zone that entered // that state first, with its endpoints. Every transition bumps the zone's // Revision, so a decision prepared against the old status fails. // // Nothing goes back to pending: a rejected zone is approved after all, removed, // or pushed out by newer rejections. An official zone is never rejected // after the fact, it is retired, so the record of it having been official // survives. func (r *Registry) ReviewZone(slug string, to Status, revision int64, by address, at int64, reason string) error { z, id, err := r.zone(slug) if err != nil { return err } if !ValidAddress(by) { return errors.New("zones: the reviewer is not a valid lowercase address") } reason = TrimSpaces(reason) if err := ValidateReason(reason); err != nil { return err } if err := stale(z, revision); err != nil { return err } if z.Status == to { // A decision is restated with a new reason, the only way to correct // one: a rejected or retired zone is not editable, and an edit to an // approved zone must change something besides its reason. if (to == Approved || to == Rejected || to == Retired) && HasVisible(reason) && reason != z.Reason { r.ids.rev++ z.Revision = int64(r.ids.rev) z.ReviewedBy, z.ReviewedAt, z.Reason = by, at, reason return nil } return errors.New("zones: " + slug + " is already " + string(to)) } switch to { case Approved: if z.Status != Pending && r.Live() >= MaxZones { return errors.New("zones: the registry is full at " + strconv.Itoa(MaxZones) + " pending and approved zones") } case Rejected: if z.Status == Approved { return errors.New("zones: " + slug + " is approved; retire it instead of rejecting it") } if z.Status != Pending { return errors.New("zones: only a pending zone can be rejected; " + slug + " is " + string(z.Status)) } case Retired: if z.Status != Approved { return errors.New("zones: only an approved zone can be retired; " + slug + " is " + string(z.Status)) } default: return errors.New("zones: a review cannot set a zone to " + strconv.Quote(string(to))) } if (to == Rejected || to == Retired) && !HasVisible(reason) { return errors.New("zones: " + string(to) + " needs a reason") } switch to { case Rejected: if err := r.unverifyAll(slug, by, at, ZoneRejected); err != nil { return err } r.makeRoom(Rejected, MaxRejected) case Retired: if err := r.unverifyAll(slug, by, at, ZoneRetired); err != nil { return err } r.makeRoom(Retired, MaxRetired) } r.leave(z, id) if to == Approved { if err := r.approved.Add(z.ChainID, id); err != nil { return err } } if err := r.enter(z, id, to); err != nil { return err } z.ReviewedBy, z.ReviewedAt, z.Reason = by, at, reason return nil } // stale refuses a decision written against a revision the zone has moved past. func stale(z *Zone, revision int64) error { if revision != z.Revision { return errors.New("zones: " + z.Slug + " changed since you read it: it is at revision " + strconv.FormatInt(z.Revision, 10) + ", you acted on revision " + strconv.FormatInt(revision, 10) + "; read it again") } return nil } // makeRoom drops the zone that has been in that state longest, when the state // is at its cap, so the one about to enter fits. func (r *Registry) makeRoom(status Status, max int) { for r.byStatus.Count(string(status)) >= max { var oldest store.ID r.entered.tree.Iterate(string(status)+"|", string(status)+"}", func(_ string, v any) bool { oldest = v.(store.ID) return true }) v, _ := r.zones.get(oldest) r.removeZone(v.(*Zone), oldest) } } // RemoveZone deletes a pending or rejected zone and every endpoint registered // on it, freeing their storage deposit to whoever signs the removal. It names // the revision it was decided on, so a removal meant for one proposal cannot // land on another proposed again under the same slug. A zone that has ever // been official cannot be removed this way: an approved one is retired, and a // retired one is kept until MaxRetired newer retirements push it out, so // whoever still holds its chain id can find out what happened to it. func (r *Registry) RemoveZone(slug string, revision int64) error { z, id, err := r.zone(slug) if err != nil { return err } if err := stale(z, revision); err != nil { return err } if z.Status == Approved { return errors.New("zones: " + slug + " is approved; retire it instead of removing it") } if z.Status == Retired { return errors.New("zones: " + slug + " is retired, and a retired zone is kept on record") } r.removeZone(z, id) return nil } // removeZone unwinds a zone, its endpoints and every index. The per-zone index // keys go in one RemoveKey each rather than an id at a time, which would copy // the bucket once per endpoint. func (r *Registry) removeZone(z *Zone, id store.ID) { slug := z.Slug for _, eid := range r.byZone.Lookup(slug) { v, ok := r.endpoints.remove(eid) if !ok { continue } r.byOwner.dec(ownerKey(slug, v.(*Endpoint).Registrant)) } // The zone's dedup keys are one contiguous range (a slug is [a-z0-9-], all // below "|"), so they are dropped without hashing each address again. keys := []string{} r.byAddress.tree.Iterate(slug+"|", slug+"}", func(k string, _ any) bool { keys = append(keys, k) return false }) for _, k := range keys { r.byAddress.remove(k) } r.flagged.drop(slug) r.fresh.drop(slug) r.byZone.RemoveKey(slug) r.unchecked.RemoveKey(slug) r.private.RemoveKey(slug) for _, k := range EndpointKinds() { r.byKind.RemoveKey(kindKey(slug, k)) } r.leave(z, id) r.bySlug.remove(slug) r.zones.remove(id) } // Register adds an endpoint to a zone, Unverified, and returns its id. // // A zone takes endpoints while it is pending or approved, and a rejected or // retired zone takes none. Who may register on a pending zone is the holding // realm's call; a URL is stored with its scheme and host lowercased and // without an empty tail (trimEmptyTail), a peer lowercased whole without its // host's terminal dot, so what is listed is what dials (Canonical, which also drops default ports and reads tcp as http, is // what they are compared in, not what is stored). func (r *Registry) Register(by address, at int64, slug string, kind EndpointKind, addr, label string) (int64, error) { return r.register(by, at, slug, kind, addr, label, true) } // RegisterExempt is Register without the admission gates // (MaxUnverifiedPerZone and MaxEndpointsPerAddress), for the reviewers a // holding realm trusts, as ProposeExempt is, and also takes a zone's last // ReservedForReviewers places; the hard cap, MaxEndpointsPerZone, still holds. func (r *Registry) RegisterExempt(by address, at int64, slug string, kind EndpointKind, addr, label string) (int64, error) { return r.register(by, at, slug, kind, addr, label, false) } func (r *Registry) register(by address, at int64, slug string, kind EndpointKind, addr, label string, gated bool) (int64, error) { z, _, err := r.zone(slug) if err != nil { return 0, err } if z.Status != Pending && z.Status != Approved { return 0, errors.New("zones: " + slug + " is " + string(z.Status) + " and takes no endpoints") } addr = TrimSpaces(addr) label = TrimSpaces(label) if kind != Seed && kind != Peer { // Validated as it will be stored, so every rule judges the string // that dials, length included. addr = trimEmptyTail(lowerAuthority(addr)) } if err := ValidateEndpoint(kind, addr); err != nil { return 0, err } if kind == Seed || kind == Peer { addr = Canonical(kind, addr) // lowercase, no terminal dot: what tm2 dials } private := IsPrivateHost(HostOf(kind, addr)) if z.Kind != Local && private { return 0, errors.New("zones: " + slug + " is a " + string(z.Kind) + " zone, and " + addr + " names a private or special-use host; only a local zone lists those") } if err := ValidateLabel(label); err != nil { return 0, err } if !ValidAddress(by) { return 0, errors.New("zones: the registrant is not a valid lowercase address") } akey := addressKey(slug, kind, addr) if r.byAddress.has(akey) { return 0, errors.New("zones: " + slug + " already lists that " + string(kind) + ": " + addr) } if n := r.byZone.Count(slug); n >= MaxEndpointsPerZone { return 0, errors.New("zones: " + slug + " is full at " + strconv.Itoa(MaxEndpointsPerZone) + " endpoints") } else if gated && n >= MaxEndpointsPerZone-ReservedForReviewers { return 0, errors.New("zones: " + slug + "'s last " + strconv.Itoa(ReservedForReviewers) + " endpoint places are kept for curators") } if n := r.Awaiting(slug); gated && n >= MaxUnverifiedPerZone { return 0, errors.New("zones: " + slug + " already has " + strconv.Itoa(n) + " endpoints waiting for review; try again once a curator has cleared some") } okey := ownerKey(slug, by) if n := r.byOwner.count(okey); gated && n >= MaxEndpointsPerAddress { return 0, errors.New("zones: " + by.String() + " already registered " + strconv.Itoa(n) + " endpoints on " + slug + ", the limit is " + strconv.Itoa(MaxEndpointsPerAddress)) } e := &Endpoint{Zone: slug, Kind: kind, Address: addr, Label: label, Registrant: by, RegisteredAt: at, Status: Unverified, Exempt: !gated} id := r.endpoints.add(e) e.ID = int64(id) r.ids.rev++ e.Revision = int64(r.ids.rev) r.byAddress.set(akey, id) if err := r.byZone.Add(slug, id); err != nil { return 0, err } if err := r.byKind.Add(kindKey(slug, kind), id); err != nil { return 0, err } r.byOwner.inc(okey) if gated { r.fresh.inc(slug) } if err := r.unchecked.Add(slug, id); err != nil { return 0, err } if private { if err := r.private.Add(slug, id); err != nil { return 0, err } } return e.ID, nil } // ReviewEndpoint records a curator's verdict on an endpoint. Any verdict may // follow any other, and the same one again with a new reason (so a typo is // corrected without passing through a state its registrant may remove it // from), with these rules: // // - every verdict names the endpoint's Revision as read, and fails if the // endpoint changed since: a verdict landing after another curator's, // unseen, would silently reverse it; // - flagging needs a reason, because "do not use this" with no why is not // something an operator can act on; // - a verification also names the zone's Revision it was checked against, // and fails if the zone changed since: what is verified is that the // endpoint answers for THIS zone's chain id, and a proposer could otherwise // switch it while the verdict is in flight. A flag or an unverify does // not, so an edit to the zone cannot hold off a warning; // - only an endpoint of a pending or approved zone can be verified. func (r *Registry) ReviewEndpoint(id int64, to Verification, zoneRevision, revision int64, by address, at int64, reason string) error { e, err := r.endpoint(id) if err != nil { return err } if !ValidAddress(by) { return errors.New("zones: the reviewer is not a valid lowercase address") } reason = TrimSpaces(reason) if err := ValidateReason(reason); err != nil { return err } switch to { case Unverified, Verified, Flagged: case "": return errors.New("zones: a review needs a verdict") default: // Exact values only: ParseVerification trims, and a " verified " stored // as written would match no filter and count as unverified forever. return errors.New("zones: unknown verification " + strconv.Quote(string(to)) + ", want verified, unverified or flagged") } if e.Status == to && (reason == e.Reason || !HasVisible(reason)) { return errors.New("zones: endpoint #" + strconv.FormatInt(id, 10) + " is already " + string(to) + "; restating it needs a new reason") } if to == Flagged && !HasVisible(reason) { return errors.New("zones: flagging an endpoint needs a reason") } z, _, err := r.zone(e.Zone) if err != nil { return err } if revision != e.Revision { return errors.New("zones: endpoint #" + strconv.FormatInt(id, 10) + " changed since you checked it: it is at revision " + strconv.FormatInt(e.Revision, 10) + ", you acted on revision " + strconv.FormatInt(revision, 10)) } if to == Verified { if z.Status != Pending && z.Status != Approved { return errors.New("zones: " + z.Slug + " is " + string(z.Status) + "; nothing on it can be verified") } if zoneRevision != z.Revision { return errors.New("zones: " + z.Slug + " changed since you checked it against it: it is at revision " + strconv.FormatInt(z.Revision, 10) + ", you verified against revision " + strconv.FormatInt(zoneRevision, 10)) } } sid := store.ID(id) if to == Verified { r.unchecked.Remove(e.Zone, sid) } else if e.Status == Verified { if err := r.unchecked.Add(e.Zone, sid); err != nil { return err } } if e.Status == Flagged { r.flagged.dec(e.Zone) } if to == Flagged { r.flagged.inc(e.Zone) } if e.Clearable() { r.fresh.dec(e.Zone) // its first verdict: somebody has ruled on it now } e.Status = to e.ReviewedBy, e.ReviewedAt, e.Reason = by, at, reason r.ids.rev++ e.Revision = int64(r.ids.rev) return nil } // unverifyAll sends every verified endpoint of a zone back to unverified with // that reason, and by as the reviewer ("" when the proposer's own edit caused // it). Flagged ones keep their flag. Only the verified ones are read: their // ids are the zone's ids minus the unchecked ones, both kept ascending, so one // merge pass finds them without loading the rest. func (r *Registry) unverifyAll(slug string, by address, at int64, reason string) error { all := r.byZone.Lookup(slug) skip := r.unchecked.Lookup(slug) j := 0 for _, id := range all { for j < len(skip) && skip[j] < id { j++ } if j < len(skip) && skip[j] == id { continue } v, ok := r.endpoints.get(id) if !ok { continue } e := v.(*Endpoint) if err := r.unchecked.Add(slug, id); err != nil { return err } e.Status = Unverified e.ReviewedBy, e.ReviewedAt, e.Reason = by, at, reason r.ids.rev++ e.Revision = int64(r.ids.rev) } return nil } // RemoveEndpoint deletes an endpoint, freeing its storage deposit to whoever // signs the removal. revision is the endpoint's Revision as read: a removal // fails if a verdict landed since, rather than delete one nobody saw. Who may // is the holding realm's call. func (r *Registry) RemoveEndpoint(id, revision int64) error { e, err := r.endpoint(id) if err != nil { return err } if revision != e.Revision { return errors.New("zones: endpoint #" + strconv.FormatInt(id, 10) + " changed since you read it: it is at revision " + strconv.FormatInt(e.Revision, 10) + ", you acted on revision " + strconv.FormatInt(revision, 10)) } r.removeEndpoint(store.ID(id)) return nil } // removeEndpoint unwinds the record and all eight of its indexes and counters // together. func (r *Registry) removeEndpoint(id store.ID) { v, ok := r.endpoints.remove(id) if !ok { return } e := v.(*Endpoint) r.byAddress.remove(addressKey(e.Zone, e.Kind, e.Address)) r.byZone.Remove(e.Zone, id) r.byKind.Remove(kindKey(e.Zone, e.Kind), id) r.byOwner.dec(ownerKey(e.Zone, e.Registrant)) r.unchecked.Remove(e.Zone, id) r.private.Remove(e.Zone, id) if e.Status == Flagged { r.flagged.dec(e.Zone) } if e.Clearable() { r.fresh.dec(e.Zone) } } // Revision is the last revision handed out, to a zone or an endpoint. Anything // that changes after a read gets a later one. func (r *Registry) Revision() int64 { return int64(r.ids.rev) } // PrivateEndpoints returns a zone's endpoints on a private host, the ones // leaving the local kind drops. Only those are read. func (r *Registry) PrivateEndpoints(slug string) []Endpoint { out := []Endpoint{} for _, id := range r.private.Lookup(slug) { if v, ok := r.endpoints.get(id); ok { out = append(out, *v.(*Endpoint)) } } return out } // Zone returns a copy of the zone under slug. func (r *Registry) Zone(slug string) (Zone, bool) { z, _, err := r.zone(slug) if err != nil { return Zone{}, false } return *z, true } // Endpoint returns a copy of the endpoint with that id. func (r *Registry) Endpoint(id int64) (Endpoint, bool) { e, err := r.endpoint(id) if err != nil { return Endpoint{}, false } return *e, true } // EndpointByAddress returns a copy of the endpoint a zone lists under that kind // and address, compared in [Canonical] form. func (r *Registry) EndpointByAddress(slug string, kind EndpointKind, addr string) (Endpoint, bool) { id, ok := r.byAddress.get(addressKey(slug, kind, addr)) if !ok { return Endpoint{}, false } v, ok := r.endpoints.get(id) if !ok { return Endpoint{}, false } return *v.(*Endpoint), true } // ZoneFilter selects zones. A zero field matches anything. type ZoneFilter struct { Status Status Kind Kind } // Match reports whether z passes the filter. func (f ZoneFilter) Match(z Zone) bool { return (f.Status == "" || z.Status == f.Status) && (f.Kind == "" || z.Kind == f.Kind) } // Zones returns copies of the zones passing f, in the order they were // proposed. With a status set it reads that status's zones only. // // The result, like Endpoints', is capped at its length. A slice handed to // another realm is readonly there: an append that fits in spare capacity // writes into it and aborts, one that does not copies and succeeds. Uncapped, // an importer's append would pass or abort by how many rows the filter // dropped, which strangers decide by registering. func (r *Registry) Zones(f ZoneFilter) []Zone { if f.Status != "" { ids := r.byStatus.Lookup(string(f.Status)) out := make([]Zone, 0, len(ids)) for _, id := range ids { v, _ := r.zones.get(id) if z := v.(*Zone); f.Match(*z) { out = append(out, *z) } } return out[:len(out):len(out)] } out := make([]Zone, 0, r.zones.len()) r.zones.each(func(v any) { if z := v.(*Zone); f.Match(*z) { out = append(out, *z) } }) return out[:len(out):len(out)] } // ZoneCount returns how many zones have that status ("" for all), without // reading any. func (r *Registry) ZoneCount(status Status) int { if status == "" { return r.zones.len() } return r.byStatus.Count(string(status)) } // Live returns how many zones are pending or approved: what MaxZones bounds. func (r *Registry) Live() int { return r.byStatus.Count(string(Pending)) + r.byStatus.Count(string(Approved)) } // ZonePage returns page (1-based) of the zones with that status ("" for every // status), size per page, in proposal order. A page past the end is empty. // // It reads the zones ON the page only; the status's id list (one object, at // most MaxZones ids) is read whole and sliced. func (r *Registry) ZonePage(status Status, page, size int) []Zone { if status == "" { n := r.zones.len() if page < 1 || size < 1 || page-1 > n/size { return []Zone{} } if size > n { size = n } out := make([]Zone, 0, size) //gnovet:ignore page-offset-overflow bounded by page-1 > n/size at the top r.zones.page((page-1)*size, size, func(v any) { out = append(out, *v.(*Zone)) }) return out } ids := pageOf(r.byStatus.Lookup(string(status)), page, size) out := make([]Zone, 0, len(ids)) for _, id := range ids { v, _ := r.zones.get(id) out = append(out, *v.(*Zone)) } return out } // ApprovedByChainID returns the approved zones naming that chain id, in // proposal order. Usually one, but chain ids are not unique across networks // (every gnodev is "dev"), so it is a list. Only approved zones are indexed, so // proposals naming a real chain id cost a reader of this nothing. func (r *Registry) ApprovedByChainID(chainID string) []Zone { ids := r.approved.Lookup(chainID) out := make([]Zone, 0, len(ids)) for _, id := range ids { v, _ := r.zones.get(id) out = append(out, *v.(*Zone)) } return out } // SoleApproved returns the approved zone naming that chain id when there is // exactly one, which is the only case a reader can be pointed at it without a // guess. It counts first and reads one record at most. func (r *Registry) SoleApproved(chainID string) (Zone, bool) { if r.approved.Count(chainID) != 1 { return Zone{}, false } id, _ := r.approved.First(chainID) v, _ := r.zones.get(id) return *v.(*Zone), true } // EndpointFilter selects endpoints. A zero field matches anything. type EndpointFilter struct { Zone string Kind EndpointKind Status Verification Registrant address } // Match reports whether e passes the filter. func (f EndpointFilter) Match(e Endpoint) bool { return (f.Zone == "" || e.Zone == f.Zone) && (f.Kind == "" || e.Kind == f.Kind) && (f.Status == "" || e.Status == f.Status) && (f.Registrant == "" || e.Registrant == f.Registrant) } // Endpoints returns copies of the endpoints passing f, oldest first. With a // zone set it reads that zone's ids only (that kind's, with a kind set), so it // costs at most MaxEndpointsPerZone records. Without one it reads every // endpoint in the registry: bound it yourself. func (r *Registry) Endpoints(f EndpointFilter) []Endpoint { if f.Zone != "" { var ids []store.ID if f.Kind != "" { ids = r.byKind.Lookup(kindKey(f.Zone, f.Kind)) } else { ids = r.byZone.Lookup(f.Zone) } out := make([]Endpoint, 0, len(ids)) for _, id := range ids { v, _ := r.endpoints.get(id) if e := v.(*Endpoint); f.Match(*e) { out = append(out, *e) } } return out[:len(out):len(out)] } out := make([]Endpoint, 0, r.endpoints.len()) r.endpoints.each(func(v any) { if e := v.(*Endpoint); f.Match(*e) { out = append(out, *e) } }) return out[:len(out):len(out)] } // Count returns how many endpoints a zone has, and how many of them are // verified, from the index counts: no endpoint is read. func (r *Registry) Count(slug string) (verified, total int) { total = r.byZone.Count(slug) return total - r.unchecked.Count(slug), total } // EndpointCount returns how many endpoints of that kind a zone has; "" is // every kind. No endpoint is read. func (r *Registry) EndpointCount(slug string, kind EndpointKind) int { if kind == "" { return r.byZone.Count(slug) } return r.byKind.Count(kindKey(slug, kind)) } // Awaiting returns how many of a zone's endpoints are waiting for a verdict, // what MaxUnverifiedPerZone gates. No endpoint is read. func (r *Registry) Awaiting(slug string) int { return r.unchecked.Count(slug) - r.flagged.count(slug) } // Clearable returns how many of a zone's endpoints are Clearable, what a bulk // clear could remove. No endpoint is read. func (r *Registry) Clearable(slug string) int { return r.fresh.count(slug) } // OwnerCount returns how many endpoints that address registered on a zone. No // endpoint is read. func (r *Registry) OwnerCount(slug string, who address) int { return r.byOwner.count(ownerKey(slug, who)) } // EndpointPage returns page (1-based) of a zone's endpoints of that kind, "" // for every kind, size per page, oldest first. Like [Registry.ZonePage] it reads // the records on the page only, and the bucket's id list (at most // MaxEndpointsPerZone ids) whole. func (r *Registry) EndpointPage(slug string, kind EndpointKind, page, size int) []Endpoint { var ids []store.ID if kind != "" { ids = r.byKind.Lookup(kindKey(slug, kind)) } else { ids = r.byZone.Lookup(slug) } ids = pageOf(ids, page, size) out := make([]Endpoint, 0, len(ids)) for _, id := range ids { v, _ := r.endpoints.get(id) out = append(out, *v.(*Endpoint)) } return out } // pageOf slices ids to one page, 1-based. Out of range is empty, never a // panic: page and size usually come from a reader's query string. func pageOf(ids []store.ID, page, size int) []store.ID { // The division comes first so a page number near MaxInt cannot overflow // the multiplication into a small positive offset. if page < 1 || size < 1 || page-1 > len(ids)/size { return nil } //gnovet:ignore page-offset-overflow bounded by page-1 > len(ids)/size above start := (page - 1) * size if start >= len(ids) { return nil } end := len(ids) if len(ids)-start > size { end = start + size } return ids[start:end] } // Len returns how many zones the registry holds, whatever their status. func (r *Registry) Len() int { return r.zones.len() } func (r *Registry) zone(slug string) (*Zone, store.ID, error) { id, ok := r.bySlug.get(slug) if !ok { return nil, 0, errors.New("zones: no zone " + strconv.Quote(slug)) } v, _ := r.zones.get(id) return v.(*Zone), id, nil } func (r *Registry) endpoint(id int64) (*Endpoint, error) { if id <= 0 { return nil, errors.New("zones: no endpoint #" + strconv.FormatInt(id, 10)) } v, ok := r.endpoints.get(store.ID(id)) if !ok { return nil, errors.New("zones: no endpoint #" + strconv.FormatInt(id, 10)) } return v.(*Endpoint), nil } // addressKey is the dedup key: the zone, the kind, and a hash of the address // in [Canonical] form. Hashed so the address part of the key is 16 bytes // whatever the URL's length, // instead of a second copy of up to MaxURLLen bytes the record already holds, // and kept raw rather than hex: it is a key, never shown. Truncated to 128 // bits, which no accidental collision reaches and which only lets a deliberate // one refuse its own registration. It is the last field, so a byte that happens // to be "|" cannot be mistaken for a separator. func addressKey(slug string, kind EndpointKind, addr string) string { sum := sha256.Sum256([]byte(Canonical(kind, addr))) return slug + "|" + string(kind) + "|" + string(sum[:16]) } func kindKey(slug string, kind EndpointKind) string { return slug + "|" + string(kind) } func ownerKey(slug string, who address) string { return slug + "|" + who.String() } // trimInfo normalizes what a caller typed before it is validated: spaces // around every field, and each URL's scheme and host lowercased in ASCII // (both are case-insensitive, gno's link sanitizer is not, so HTTPS:// would // render as a dead link), and a gnoweb or genesis URL's empty tail dropped // (trimEmptyTail), as an address bar writes it. Nothing else is rewritten: // what fails validation is refused, not repaired, and the main RPC with an // empty tail is refused. func trimInfo(in Info) Info { in.ChainID = TrimSpaces(in.ChainID) in.Title = TrimSpaces(in.Title) in.Description = TrimSpaces(in.Description) in.Kind = Kind(TrimSpaces(string(in.Kind))) in.GnowebURL = trimEmptyTail(lowerAuthority(TrimSpaces(in.GnowebURL))) in.RPCURL = lowerAuthority(TrimSpaces(in.RPCURL)) in.GenesisURL = trimEmptyTail(lowerAuthority(TrimSpaces(in.GenesisURL))) return in } // table is numbered records in a B+ tree: kit/store's shape (an // id that is never reused, keyed by its seqid encoding so the tree iterates in // id order) on a B+ tree, the keyed, ordered container EFFECTIVE_GNO.md // recommends for iteration and pagination. kit/store/v0 sits on an avl, which // it measured at 2,029 B per entry against a B+ tree's 592 to 671, and about // six times the gas per entry iterated, and on an immutable path that choice // is permanent. type table struct { tree *bptree.BPTree last uint64 } func newTable() *table { return &table{tree: bptree.NewBPTreeN(fanout)} } func (t *table) add(v any) store.ID { t.last++ id := store.ID(t.last) t.tree.Set(id.Key(), v) return id } func (t *table) get(id store.ID) (any, bool) { v := t.tree.Get(id.Key()) return v, v != nil } func (t *table) remove(id store.ID) (any, bool) { return t.tree.Remove(id.Key()) } func (t *table) len() int { return t.tree.Size() } func (t *table) each(fn func(v any)) { t.tree.IterateByOffset(0, t.tree.Size(), func(_ string, v any) bool { fn(v) return false }) } func (t *table) page(offset, count int, fn func(v any)) { t.tree.IterateByOffset(offset, count, func(_ string, v any) bool { fn(v) return false }) } // unique is a key -> id map in a B+ tree that holds the id itself. kit/index // stores every key as three objects (a box, an entry struct, an ids array), and // for a key that only ever has one id two of them say nothing. type unique struct{ tree *bptree.BPTree } func newUnique() *unique { return &unique{tree: bptree.NewBPTreeN(fanout)} } func (u *unique) has(key string) bool { return u.tree.Has(key) } func (u *unique) get(key string) (store.ID, bool) { v := u.tree.Get(key) if v == nil { return 0, false } return v.(store.ID), true } func (u *unique) set(key string, id store.ID) { u.tree.Set(key, id) } func (u *unique) remove(key string) { u.tree.Remove(key) } // counter is a key -> count map in a B+ tree, one object per key. What it // answers (pending proposals per proposer and endpoints per registrant per // zone for the caps, flagged endpoints per zone for the admission gate, // clearable endpoints per zone for the bulk clear) only // ever asks how many, so keeping the ids would be two more objects per key, // written and deleted for nothing. type counter struct{ tree *bptree.BPTree } func newCounter() *counter { return &counter{tree: bptree.NewBPTreeN(fanout)} } func (c *counter) count(key string) int { v := c.tree.Get(key) if v == nil { return 0 } return v.(int) } func (c *counter) inc(key string) { c.tree.Set(key, c.count(key)+1) } func (c *counter) dec(key string) { if n := c.count(key) - 1; n > 0 { c.tree.Set(key, n) } else { c.tree.Remove(key) } } func (c *counter) drop(key string) { c.tree.Remove(key) }
  8. #8registry_test.gno
  9. #9package zones import ( "strconv" "strings" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) var ( alice = testutils.TestAddress("alice") bob = testutils.TestAddress("bob") curator = testutils.TestAddress("curator") carol = testutils.TestAddress("carol") ) func onyx() Info { return Info{ ChainID: "onyx-1", Title: "Onyx", Description: "The gno.land testnet.", Kind: Testnet, GnowebURL: "https://onyx.testnets.gno.land", RPCURL: "https://rpc.onyx.testnets.gno.land", } } func TestProposeAndRead(t *testing.T) { r := NewRegistry() in := onyx() in.Title = " Onyx " uassert.NoError(t, r.Propose(alice, 10, "onyx", in)) z, ok := r.Zone("onyx") uassert.True(t, ok) uassert.Equal(t, "Onyx", z.Title) // trimmed uassert.Equal(t, string(Pending), string(z.Status)) uassert.Equal(t, alice.String(), z.Proposer.String()) uassert.Equal(t, int64(10), z.ProposedAt) uassert.True(t, !z.Reviewed()) _, ok = r.Zone("nope") uassert.False(t, ok) uassert.ErrorContains(t, r.Propose(bob, 11, "onyx", onyx()), "is taken") uassert.ErrorContains(t, r.Propose(bob, 11, "On yx", onyx()), "slug") uassert.ErrorContains(t, r.Propose("", 11, "other", onyx()), "proposer is not a valid lowercase address") uassert.Equal(t, 1, r.Len()) } func TestZoneCopiesAreCopies(t *testing.T) { r := NewRegistry() uassert.NoError(t, r.Propose(alice, 1, "onyx", onyx())) z, _ := r.Zone("onyx") z.Title = "mutated" z.Status = Approved again, _ := r.Zone("onyx") uassert.Equal(t, "Onyx", again.Title) uassert.Equal(t, string(Pending), string(again.Status)) } func TestPendingCapPerProposer(t *testing.T) { r := NewRegistry() for i := 0; i < MaxPendingPerProposer; i++ { uassert.NoError(t, r.Propose(alice, 1, "z"+strconv.Itoa(i), onyx())) } uassert.ErrorContains(t, r.Propose(alice, 1, "one-more", onyx()), "pending proposals, the limit is") // Somebody else is not blocked by alice's spam. uassert.NoError(t, r.Propose(bob, 1, "bobs", onyx())) // A reviewed proposal frees the slot. uassert.NoError(t, r.ReviewZone("z0", Approved, rev(r, "z0"), curator, 2, "")) uassert.NoError(t, r.Propose(alice, 3, "one-more", onyx())) // So does a removed one. uassert.NoError(t, r.RemoveZone("z1", rev(r, "z1"))) uassert.NoError(t, r.Propose(alice, 3, "and-another", onyx())) } func TestReviewTransitions(t *testing.T) { cases := []struct { from Status to Status reason string err string }{ {Pending, Approved, "", ""}, {Pending, Rejected, "duplicate of onyx", ""}, {Pending, Rejected, "", "needs a reason"}, {Pending, Rejected, "\u200b", "invisible or bidi"}, {Approved, Retired, "\u2066\u2069", "invisible or bidi"}, {Pending, Retired, "gone", "only an approved zone can be retired"}, {Pending, Pending, "", "already pending"}, {Approved, Retired, "the RPC stopped resolving", ""}, {Approved, Retired, " ", "needs a reason"}, {Approved, Rejected, "x", "retire it instead"}, {Approved, Approved, "", "already approved"}, {Rejected, Approved, "reconsidered", ""}, {Rejected, Retired, "x", "only an approved zone"}, {Retired, Approved, "it came back", ""}, {Retired, Rejected, "x", "only a pending zone can be rejected"}, {Pending, "official", "", "cannot set a zone"}, } for _, c := range cases { r := NewRegistry() uassert.NoError(t, r.Propose(alice, 1, "onyx", onyx())) // Walk the zone to the starting state. switch c.from { case Approved: uassert.NoError(t, r.ReviewZone("onyx", Approved, rev(r, "onyx"), curator, 2, "")) case Rejected: uassert.NoError(t, r.ReviewZone("onyx", Rejected, rev(r, "onyx"), curator, 2, "setup")) case Retired: uassert.NoError(t, r.ReviewZone("onyx", Approved, rev(r, "onyx"), curator, 2, "")) uassert.NoError(t, r.ReviewZone("onyx", Retired, rev(r, "onyx"), curator, 3, "setup")) } label := string(c.from) + " -> " + string(c.to) err := r.ReviewZone("onyx", c.to, rev(r, "onyx"), curator, 9, c.reason) z, _ := r.Zone("onyx") if c.err != "" { uassert.ErrorContains(t, err, c.err, label) uassert.Equal(t, string(c.from), string(z.Status), label) continue } uassert.NoError(t, err, label) uassert.Equal(t, string(c.to), string(z.Status), label) uassert.Equal(t, curator.String(), z.ReviewedBy.String(), label) uassert.Equal(t, int64(9), z.ReviewedAt, label) uassert.Equal(t, c.reason, z.Reason, label) } } func TestEdit(t *testing.T) { r := NewRegistry() uassert.NoError(t, r.Propose(alice, 1, "onyx", onyx())) in := onyx() in.GenesisURL = "https://github.com/gnolang/gno/releases/download/chain/onyx/genesis.json" uassert.ErrorContains(t, r.Edit("onyx", rev(r, "onyx"), in, "", 2, ""), "editor is not a valid lowercase address") uassert.NoError(t, r.Edit("onyx", rev(r, "onyx"), in, alice, 2, "")) z, _ := r.Zone("onyx") uassert.Equal(t, in.GenesisURL, z.GenesisURL) uassert.False(t, z.Reviewed()) // pending: just an edit bad := in bad.RPCURL = "" uassert.ErrorContains(t, r.Edit("onyx", rev(r, "onyx"), bad, alice, 2, ""), "rpc url is empty") uassert.ErrorContains(t, r.Edit("nope", rev(r, "nope"), in, alice, 2, ""), "no zone") z, _ = r.Zone("onyx") uassert.Equal(t, "https://rpc.onyx.testnets.gno.land", z.RPCURL) // unchanged by the refused edit // Once reviewed, an edit is a decision: it needs a reason and an editor, and // it replaces the review on record. uassert.NoError(t, r.ReviewZone("onyx", Approved, rev(r, "onyx"), curator, 3, "probed")) in.RPCURL = "https://rpc2.onyx.example.com" uassert.ErrorContains(t, r.Edit("onyx", rev(r, "onyx"), in, curator, 4, ""), "an edit needs a reason") uassert.ErrorContains(t, r.Edit("onyx", rev(r, "onyx"), in, "", 4, "moved"), "editor is not a valid lowercase address") z, _ = r.Zone("onyx") uassert.Equal(t, "https://rpc.onyx.testnets.gno.land", z.RPCURL) uassert.Equal(t, "probed", z.Reason) uassert.NoError(t, r.Edit("onyx", rev(r, "onyx"), in, bob, 5, "rpc moved")) z, _ = r.Zone("onyx") uassert.Equal(t, "https://rpc2.onyx.example.com", z.RPCURL) uassert.Equal(t, bob.String(), z.ReviewedBy.String()) uassert.Equal(t, int64(5), z.ReviewedAt) uassert.Equal(t, "rpc moved", z.Reason) uassert.Equal(t, string(Approved), string(z.Status)) // A retired zone is a record: its retirement reason is not editable away. uassert.NoError(t, r.ReviewZone("onyx", Retired, rev(r, "onyx"), curator, 6, "testnet ended")) uassert.ErrorContains(t, r.Edit("onyx", rev(r, "onyx"), in, curator, 7, "typo"), "only a pending or approved zone can be edited") z, _ = r.Zone("onyx") uassert.Equal(t, "testnet ended", z.Reason) // Nor is a rejected one. uassert.NoError(t, r.Propose(alice, 8, "nope", onyx())) uassert.NoError(t, r.ReviewZone("nope", Rejected, rev(r, "nope"), curator, 9, "dup")) uassert.ErrorContains(t, r.Edit("nope", rev(r, "nope"), onyx(), alice, 10, ""), "only a pending or approved zone can be edited") } // What was verified is that an endpoint answered for the zone's chain id, so // changing the chain id un-verifies them; changing only the RPC does not. func TestChainIDEditUnverifiesEndpoints(t *testing.T) { r := NewRegistry() uassert.NoError(t, r.Propose(curator, 1, "onyx", onyx())) uassert.NoError(t, r.ReviewZone("onyx", Approved, rev(r, "onyx"), curator, 1, "")) a, _ := r.Register(alice, 2, "onyx", RPC, "https://a.example.com", "") b, _ := r.Register(alice, 2, "onyx", Peer, nodeID+"@b.example.com:26656", "") f, _ := r.Register(alice, 2, "onyx", RPC, "https://f.example.com", "") uassert.NoError(t, r.ReviewEndpoint(a, Verified, zr(r, a), erev(r, a), curator, 3, "")) uassert.NoError(t, r.ReviewEndpoint(b, Verified, zr(r, b), erev(r, b), curator, 3, "")) uassert.NoError(t, r.ReviewEndpoint(f, Flagged, zr(r, f), erev(r, f), curator, 3, "down")) in := onyx() in.RPCURL = "https://rpc2.onyx.example.com" uassert.NoError(t, r.Edit("onyx", rev(r, "onyx"), in, curator, 4, "rpc moved")) uassert.Equal(t, 2, len(r.Endpoints(EndpointFilter{Zone: "onyx", Status: Verified}))) in.ChainID = "onyx-2" uassert.NoError(t, r.Edit("onyx", rev(r, "onyx"), in, curator, 5, "relaunched as onyx-2")) uassert.Equal(t, 0, len(r.Endpoints(EndpointFilter{Zone: "onyx", Status: Verified}))) e, _ := r.Endpoint(a) uassert.Equal(t, string(Unverified), string(e.Status)) uassert.Equal(t, ChainIDChanged, e.Reason) uassert.Equal(t, int64(5), e.ReviewedAt) e, _ = r.Endpoint(f) uassert.Equal(t, string(Flagged), string(e.Status)) // a flag is not undone by an edit uassert.Equal(t, "down", e.Reason) // And the reset ones count against the review queue again (the flagged // one has its verdict and stays out): verifying one frees it. v, _ := r.Count("onyx") uassert.Equal(t, 0, v) uassert.Equal(t, 2, r.Awaiting("onyx"), "both reset endpoints are back in the queue") uassert.NoError(t, r.ReviewEndpoint(a, Verified, zr(r, a), erev(r, a), curator, 6, "answers onyx-2")) uassert.Equal(t, 1, len(r.Endpoints(EndpointFilter{Zone: "onyx", Status: Verified}))) v, _ = r.Count("onyx") uassert.Equal(t, 1, v) // Retiring resets what was verified, at the moment the network stops: a // retired network's hosts may be somebody else's by the time anyone reads // them. Approving it again does not restore anything. uassert.NoError(t, r.ReviewEndpoint(b, Verified, zr(r, b), erev(r, b), curator, 6, "")) uassert.NoError(t, r.ReviewZone("onyx", Retired, rev(r, "onyx"), curator, 6, "stopped")) uassert.Equal(t, 0, len(r.Endpoints(EndpointFilter{Zone: "onyx", Status: Verified}))) e, _ = r.Endpoint(b) uassert.Equal(t, ZoneRetired, e.Reason) uassert.Equal(t, curator.String(), e.ReviewedBy.String()) uassert.ErrorContains(t, r.ReviewEndpoint(b, Verified, zr(r, b), erev(r, b), curator, 6, ""), "nothing on it can be verified") uassert.NoError(t, r.ReviewZone("onyx", Approved, rev(r, "onyx"), curator, 7, "back up")) uassert.Equal(t, 0, len(r.Endpoints(EndpointFilter{Zone: "onyx", Status: Verified}))) e, _ = r.Endpoint(f) uassert.Equal(t, string(Flagged), string(e.Status)) // A pending zone is no exception: a proposer collecting verdicts for one // chain id and then switching it gets them all reset. uassert.NoError(t, r.Propose(alice, 7, "pend", onyx())) pid, _ := r.Register(alice, 7, "pend", RPC, "https://p.example.com", "") uassert.NoError(t, r.ReviewEndpoint(pid, Verified, zr(r, pid), erev(r, pid), curator, 8, "")) other := onyx() other.ChainID = "elsewhere-1" uassert.NoError(t, r.Edit("pend", rev(r, "pend"), other, alice, 9, "")) e, _ = r.Endpoint(pid) uassert.Equal(t, string(Unverified), string(e.Status)) // The proposer's own edit caused the reset, and the proposer is not a // reviewer, so none is recorded, and the reason says why. uassert.Equal(t, "", e.ReviewedBy.String()) uassert.Equal(t, ChainIDChanged, e.Reason) z, _ := r.Zone("pend") uassert.False(t, z.Reviewed()) // still a pending edit: the zone's own review is untouched } func TestRegisterAndFilter(t *testing.T) { r := NewRegistry() uassert.NoError(t, r.Propose(curator, 1, "onyx", onyx())) uassert.NoError(t, r.ReviewZone("onyx", Approved, rev(r, "onyx"), curator, 1, "")) rpc, err := r.Register(alice, 5, "onyx", RPC, " https://rpc.onyx.testnets.gno.land ", "gno core") uassert.NoError(t, err) peer, err := r.Register(bob, 6, "onyx", Peer, nodeID+"@seed-1.onyx.testnets.gno.land:26656", "") uassert.NoError(t, err) uassert.True(t, rpc > 0 && peer > rpc) e, ok := r.Endpoint(rpc) uassert.True(t, ok) uassert.Equal(t, rpc, e.ID) uassert.Equal(t, "https://rpc.onyx.testnets.gno.land", e.Address) // trimmed uassert.Equal(t, string(Unverified), string(e.Status)) uassert.Equal(t, alice.String(), e.Registrant.String()) // Dedup is case-insensitive: the same host under another spelling. _, err = r.Register(bob, 7, "onyx", RPC, "https://RPC.onyx.testnets.gno.land", "") uassert.ErrorContains(t, err, "already lists that rpc") // ...but a path is case-sensitive, so another path is another endpoint. _, err = r.Register(bob, 7, "onyx", Indexer, "https://indexer.example.com/API", "") uassert.NoError(t, err) _, err = r.Register(bob, 7, "onyx", Indexer, "https://INDEXER.example.com/API", "") uassert.ErrorContains(t, err, "already lists that indexer") _, err = r.Register(bob, 7, "onyx", Indexer, "https://indexer.example.com/api", "") uassert.NoError(t, err) // The same address under another kind is a different endpoint. _, err = r.Register(bob, 7, "onyx", Gnoweb, "https://rpc.onyx.testnets.gno.land", "") uassert.NoError(t, err) _, err = r.Register(bob, 7, "nope", RPC, "https://x.y", "") uassert.ErrorContains(t, err, "no zone") _, err = r.Register(bob, 7, "onyx", Peer, "https://x.y", "") uassert.ErrorContains(t, err, "<node id>@") _, err = r.Register(bob, 7, "onyx", RPC, "https://x.y", "two\nlines") uassert.ErrorContains(t, err, "control character") uassert.Equal(t, 5, len(r.Endpoints(EndpointFilter{Zone: "onyx"}))) uassert.Equal(t, 1, len(r.Endpoints(EndpointFilter{Zone: "onyx", Kind: Peer}))) uassert.Equal(t, 4, len(r.Endpoints(EndpointFilter{Registrant: bob}))) uassert.Equal(t, 0, len(r.Endpoints(EndpointFilter{Zone: "onyx", Status: Verified}))) uassert.NoError(t, r.ReviewEndpoint(rpc, Verified, zr(r, rpc), erev(r, rpc), curator, 8, "answers onyx-1")) vs := r.Endpoints(EndpointFilter{Zone: "onyx", Status: Verified}) uassert.Equal(t, 1, len(vs)) uassert.Equal(t, "answers onyx-1", vs[0].Reason) v, total := r.Count("onyx") uassert.Equal(t, 1, v) uassert.Equal(t, 5, total) uassert.ErrorContains(t, r.ReviewEndpoint(rpc, Verified, zr(r, rpc), erev(r, rpc), curator, 8, "answers onyx-1"), "already verified") uassert.ErrorContains(t, r.ReviewEndpoint(peer, Flagged, zr(r, peer), erev(r, peer), curator, 8, ""), "needs a reason") uassert.ErrorContains(t, r.ReviewEndpoint(peer, "trusted", zr(r, peer), erev(r, peer), curator, 8, ""), "unknown verification") uassert.ErrorContains(t, r.ReviewEndpoint(peer, "", zr(r, peer), erev(r, peer), curator, 8, ""), "needs a verdict") uassert.ErrorContains(t, r.ReviewEndpoint(peer, " verified ", zr(r, peer), erev(r, peer), curator, 8, ""), "unknown verification") _, err = r.Register(bob, 7, "onyx", " rpc ", "not a url", "") uassert.ErrorContains(t, err, "unknown endpoint kind") // A scheme's case is not a different endpoint. _, err = r.Register(bob, 7, "onyx", Explorer, "HTTPS://Explorer.example.com/x", "") uassert.NoError(t, err) _, err = r.Register(bob, 7, "onyx", Explorer, "https://explorer.example.com/x", "") uassert.ErrorContains(t, err, "already lists that explorer") uassert.ErrorContains(t, r.ReviewEndpoint(999, Verified, zr(r, 999), erev(r, 999), curator, 8, ""), "no endpoint #999") uassert.ErrorContains(t, r.ReviewEndpoint(0, Verified, zr(r, 0), erev(r, 0), curator, 8, ""), "no endpoint #0") uassert.NoError(t, r.ReviewEndpoint(peer, Flagged, zr(r, peer), erev(r, peer), curator, 9, "wrong chain id")) uassert.NoError(t, r.ReviewEndpoint(peer, Unverified, zr(r, peer), erev(r, peer), curator, 10, "fixed, re-checking")) // Removing frees the dedup key, so the same address can come back. uassert.NoError(t, r.RemoveEndpoint(rpc, erev(r, rpc))) _, ok = r.Endpoint(rpc) uassert.False(t, ok) uassert.ErrorContains(t, r.RemoveEndpoint(rpc, erev(r, rpc)), "no endpoint") _, err = r.Register(bob, 11, "onyx", RPC, "https://rpc.onyx.testnets.gno.land", "") uassert.NoError(t, err) } func TestRegisterNeedsALiveZone(t *testing.T) { r := NewRegistry() uassert.NoError(t, r.Propose(alice, 1, "onyx", onyx())) // Pending takes endpoints: a proposer fills in the peers before review. _, err := r.Register(alice, 2, "onyx", RPC, "https://a.example.com", "") uassert.NoError(t, err) uassert.NoError(t, r.ReviewZone("onyx", Rejected, rev(r, "onyx"), curator, 3, "not a gno chain")) _, err = r.Register(alice, 4, "onyx", RPC, "https://b.example.com", "") uassert.ErrorContains(t, err, "is rejected and takes no endpoints") } func TestEndpointCapPerAddress(t *testing.T) { r := NewRegistry() uassert.NoError(t, r.Propose(alice, 1, "onyx", onyx())) for i := 0; i < MaxEndpointsPerAddress; i++ { _, err := r.Register(alice, 2, "onyx", RPC, "https://n"+strconv.Itoa(i)+".example.com", "") uassert.NoError(t, err) } _, err := r.Register(alice, 2, "onyx", RPC, "https://over.example.com", "") uassert.ErrorContains(t, err, "the limit is") // Bob still can. _, err = r.Register(bob, 2, "onyx", RPC, "https://over.example.com", "") uassert.NoError(t, err) } func TestRemoveZone(t *testing.T) { r := NewRegistry() uassert.NoError(t, r.Propose(alice, 1, "onyx", onyx())) _, err := r.Register(bob, 2, "onyx", RPC, "https://a.example.com", "") uassert.NoError(t, err) uassert.NoError(t, r.ReviewZone("onyx", Approved, rev(r, "onyx"), curator, 3, "")) uassert.ErrorContains(t, r.RemoveZone("onyx", rev(r, "onyx")), "retire it instead") uassert.NoError(t, r.ReviewZone("onyx", Retired, rev(r, "onyx"), curator, 4, "testnet ended")) uassert.ErrorContains(t, r.RemoveZone("onyx", rev(r, "onyx")), "kept on record") _, ok := r.Zone("onyx") uassert.True(t, ok) // A rejected zone is removable, endpoints and all. uassert.NoError(t, r.Propose(alice, 5, "spam", onyx())) _, err = r.Register(bob, 5, "spam", RPC, "https://a.example.com", "") uassert.NoError(t, err) uassert.NoError(t, r.ReviewZone("spam", Rejected, rev(r, "spam"), curator, 6, "not a network")) uassert.NoError(t, r.RemoveZone("spam", rev(r, "spam"))) _, ok = r.Zone("spam") uassert.False(t, ok) uassert.Equal(t, 0, len(r.Endpoints(EndpointFilter{Zone: "spam"}))) uassert.Equal(t, 1, r.Len()) uassert.ErrorContains(t, r.RemoveZone("spam", rev(r, "spam")), "no zone") // The slug and the address are free again. uassert.NoError(t, r.Propose(bob, 7, "spam", onyx())) _, err = r.Register(bob, 8, "spam", RPC, "https://a.example.com", "") uassert.NoError(t, err) } func TestReviewNeedsAReviewer(t *testing.T) { r := NewRegistry() uassert.NoError(t, r.Propose(alice, 1, "onyx", onyx())) uassert.ErrorContains(t, r.ReviewZone("onyx", Approved, rev(r, "onyx"), "", 2, ""), "reviewer is not a valid lowercase address") z, _ := r.Zone("onyx") uassert.Equal(t, string(Pending), string(z.Status)) id, err := r.Register(alice, 2, "onyx", RPC, "https://a.example.com", "") uassert.NoError(t, err) uassert.ErrorContains(t, r.ReviewEndpoint(id, Verified, zr(r, id), erev(r, id), "", 3, ""), "reviewer is not a valid lowercase address") } // A flood from many addresses fills the review queue and nothing above it. func TestFloodCannotCrowdOutReviewedEntries(t *testing.T) { r := NewRegistry() uassert.NoError(t, r.Propose(curator, 1, "onyx", onyx())) uassert.NoError(t, r.ReviewZone("onyx", Approved, rev(r, "onyx"), curator, 1, "")) for i := 0; i < MaxPending; i++ { uassert.NoError(t, r.Propose(testAddr(i), 2, "flood-"+strconv.Itoa(i), onyx())) } uassert.ErrorContains(t, r.Propose(bob, 3, "honest", onyx()), "waiting for review") uassert.Equal(t, MaxPending+1, r.Len()) // Clearing one makes room. uassert.NoError(t, r.ReviewZone("flood-0", Rejected, rev(r, "flood-0"), curator, 4, "spam")) uassert.NoError(t, r.Propose(bob, 5, "honest", onyx())) // Endpoints: those waiting for a verdict have their own gate, and verifying frees it. ids := []int64{} for i := 0; i < MaxUnverifiedPerZone; i++ { id, err := r.Register(testAddr(i/MaxEndpointsPerAddress), 6, "onyx", RPC, "https://n"+strconv.Itoa(i)+".example.com", "") uassert.NoError(t, err) ids = append(ids, id) } _, err := r.Register(bob, 7, "onyx", RPC, "https://honest.example.com", "") uassert.ErrorContains(t, err, "waiting for review") uassert.NoError(t, r.ReviewEndpoint(ids[0], Verified, zr(r, ids[0]), erev(r, ids[0]), curator, 8, "")) _, err = r.Register(bob, 9, "onyx", RPC, "https://honest.example.com", "") uassert.NoError(t, err) // The gate is full again (the honest one is waiting). A flag is a verdict, // so a flagged endpoint leaves the queue: curators keep the warning and // still make room, instead of deleting the warning to make room. _, err = r.Register(bob, 11, "onyx", RPC, "https://honest2.example.com", "") uassert.ErrorContains(t, err, "waiting for review") uassert.NoError(t, r.ReviewEndpoint(ids[1], Flagged, zr(r, ids[1]), erev(r, ids[1]), curator, 12, "spam")) _, err = r.Register(bob, 13, "onyx", RPC, "https://honest2.example.com", "") uassert.NoError(t, err) // Unflagging puts it back in the queue, and a review is never refused for // the queue being full, only a registration is. uassert.NoError(t, r.ReviewEndpoint(ids[1], Unverified, zr(r, ids[1]), erev(r, ids[1]), curator, 14, "re-checking")) _, err = r.Register(bob, 15, "onyx", RPC, "https://honest3.example.com", "") uassert.ErrorContains(t, err, "waiting for review") uassert.NoError(t, r.RemoveEndpoint(ids[2], erev(r, ids[2]))) uassert.NoError(t, r.RemoveEndpoint(ids[3], erev(r, ids[3]))) _, err = r.Register(bob, 16, "onyx", RPC, "https://honest3.example.com", "") uassert.NoError(t, err) } func testAddr(i int) address { return testutils.TestAddress("flood" + strconv.Itoa(i)) } func TestZonesFilterAndOrder(t *testing.T) { r := NewRegistry() mainnet := onyx() mainnet.ChainID, mainnet.Kind = "gnoland-1", Mainnet uassert.NoError(t, r.Propose(curator, 1, "mainnet", mainnet)) uassert.NoError(t, r.Propose(curator, 1, "onyx", onyx())) uassert.NoError(t, r.Propose(alice, 2, "alices", onyx())) uassert.NoError(t, r.ReviewZone("mainnet", Approved, rev(r, "mainnet"), curator, 3, "")) uassert.NoError(t, r.ReviewZone("onyx", Approved, rev(r, "onyx"), curator, 3, "")) slugs := func(zs []Zone) string { s := "" for _, z := range zs { s += z.Slug + " " } return s } uassert.Equal(t, "mainnet onyx alices ", slugs(r.Zones(ZoneFilter{}))) uassert.Equal(t, "mainnet onyx ", slugs(r.Zones(ZoneFilter{Status: Approved}))) uassert.Equal(t, "alices ", slugs(r.Zones(ZoneFilter{Status: Pending}))) uassert.Equal(t, "onyx alices ", slugs(r.Zones(ZoneFilter{Kind: Testnet}))) uassert.Equal(t, "onyx ", slugs(r.Zones(ZoneFilter{Status: Approved, Kind: Testnet}))) uassert.Equal(t, "", slugs(r.Zones(ZoneFilter{Kind: Local}))) } func TestPagesAndCounts(t *testing.T) { r := NewRegistry() for i := 0; i < 7; i++ { uassert.NoError(t, r.Propose(testAddr(i), 1, "z"+strconv.Itoa(i), onyx())) } uassert.NoError(t, r.ReviewZone("z1", Approved, rev(r, "z1"), curator, 2, "")) uassert.NoError(t, r.ReviewZone("z3", Approved, rev(r, "z3"), curator, 2, "")) uassert.NoError(t, r.ReviewZone("z5", Approved, rev(r, "z5"), curator, 2, "")) uassert.Equal(t, 3, r.ZoneCount(Approved)) uassert.Equal(t, 4, r.ZoneCount(Pending)) uassert.Equal(t, 7, r.ZoneCount("")) page := func(zs []Zone) string { out := "" for _, z := range zs { out += z.Slug + " " } return out } uassert.Equal(t, "z1 z3 ", page(r.ZonePage(Approved, 1, 2))) uassert.Equal(t, "z5 ", page(r.ZonePage(Approved, 2, 2))) uassert.Equal(t, "", page(r.ZonePage(Approved, 3, 2))) uassert.Equal(t, "", page(r.ZonePage(Approved, 0, 2))) uassert.Equal(t, "", page(r.ZonePage(Approved, 1, 0))) uassert.Equal(t, "", page(r.ZonePage(Approved, 1<<62, 1<<10)), "no overflow into a small offset") uassert.Equal(t, "z0 z2 z4 z6 ", page(r.ZonePage(Pending, 1, 10))) // "" is every status, consistent with ZoneCount("") and ZoneFilter{}. uassert.Equal(t, "z0 z1 z2 ", page(r.ZonePage("", 1, 3))) uassert.Equal(t, "z6 ", page(r.ZonePage("", 3, 3))) uassert.Equal(t, "", page(r.ZonePage("", 4, 3))) uassert.Equal(t, "", page(r.ZonePage("", 0, 3))) uassert.Equal(t, "", page(r.ZonePage("", 1<<62, 1<<10)), "no overflow into a small offset") uassert.Equal(t, "z0 z1 z2 z3 z4 z5 z6 ", page(r.ZonePage("", 1, 1<<40))) // The status index follows a review and a removal. uassert.NoError(t, r.ReviewZone("z0", Rejected, rev(r, "z0"), curator, 3, "dup")) uassert.NoError(t, r.RemoveZone("z2", rev(r, "z2"))) uassert.Equal(t, "z4 z6 ", page(r.ZonePage(Pending, 1, 10))) uassert.Equal(t, "z0 ", page(r.ZonePage(Rejected, 1, 10))) uassert.Equal(t, "z1 z3 z5 ", page(r.Zones(ZoneFilter{Status: Approved}))) // Chain ids are not unique, only approved zones are indexed by one (so a // proposal naming a real chain id costs a reader nothing), and the index // follows an edit and a retirement. uassert.Equal(t, "z1 z3 z5 ", page(r.ApprovedByChainID("onyx-1"))) in := onyx() in.ChainID = "onyx-2" uassert.NoError(t, r.Edit("z1", rev(r, "z1"), in, curator, 4, "relaunched")) uassert.Equal(t, "z1 ", page(r.ApprovedByChainID("onyx-2"))) uassert.Equal(t, "z3 z5 ", page(r.ApprovedByChainID("onyx-1"))) uassert.NoError(t, r.Edit("z4", rev(r, "z4"), in, alice, 4, "")) // pending: not indexed uassert.Equal(t, "z1 ", page(r.ApprovedByChainID("onyx-2"))) uassert.NoError(t, r.ReviewZone("z5", Retired, rev(r, "z5"), curator, 4, "gone")) uassert.Equal(t, "z3 ", page(r.ApprovedByChainID("onyx-1"))) // Endpoints: counts from the indexes, pages by kind. for i := 0; i < 5; i++ { _, err := r.Register(alice, 5, "z3", RPC, "https://e"+strconv.Itoa(i)+".example.com", "") uassert.NoError(t, err) } pid, err := r.Register(alice, 5, "z3", Peer, nodeID+"@p.example.com:26656", "") uassert.NoError(t, err) uassert.NoError(t, r.ReviewEndpoint(pid, Verified, zr(r, pid), erev(r, pid), curator, 6, "")) v, total := r.Count("z3") uassert.Equal(t, 1, v) uassert.Equal(t, 6, total) uassert.Equal(t, 5, r.EndpointCount("z3", RPC)) uassert.Equal(t, 1, r.EndpointCount("z3", Peer)) uassert.Equal(t, 0, r.EndpointCount("z3", Faucet)) uassert.Equal(t, 6, r.EndpointCount("z3", "")) uassert.Equal(t, 1, len(r.EndpointPage("z3", RPC, 3, 2))) // page 3 of 5 rpcs at 2 a page holds one uassert.Equal(t, "https://e4.example.com", r.EndpointPage("z3", RPC, 3, 2)[0].Address) uassert.Equal(t, nodeID+"@p.example.com:26656", r.EndpointPage("z3", Peer, 1, 10)[0].Address) uassert.Equal(t, 6, len(r.EndpointPage("z3", "", 1, 10))) uassert.Equal(t, 1, len(r.Endpoints(EndpointFilter{Zone: "z3", Kind: Peer}))) uassert.NoError(t, r.RemoveEndpoint(pid, erev(r, pid))) uassert.Equal(t, 0, r.EndpointCount("z3", Peer)) v, total = r.Count("z3") uassert.Equal(t, 0, v) uassert.Equal(t, 5, total) } // rev is a zone's current revision, what a curator who just read it approves. func rev(r *Registry, slug string) int64 { z, _ := r.Zone(slug) return z.Revision } // erev is an endpoint's revision, what a verdict or a removal names. func erev(r *Registry, id int64) int64 { e, ok := r.Endpoint(id) if !ok { return 0 } return e.Revision } // zr is the revision of an endpoint's zone, what a verification also names. func zr(r *Registry, id int64) int64 { e, ok := r.Endpoint(id) if !ok { return 0 } return rev(r, e.Zone) } // An approval names the revision the curator read. An edit landing between the // reading and the approval makes the approval fail, rather than making text the // curator never saw official under their name. Remove-and-propose-again cannot // reuse a revision either. func TestApprovalBindsToRevision(t *testing.T) { r := NewRegistry() uassert.NoError(t, r.Propose(alice, 1, "onyx", onyx())) read := rev(r, "onyx") swapped := onyx() swapped.RPCURL = "https://attacker.example.com" uassert.NoError(t, r.Edit("onyx", rev(r, "onyx"), swapped, alice, 2, "")) uassert.ErrorContains(t, r.ReviewZone("onyx", Approved, read, curator, 3, ""), "changed since you read it") z, _ := r.Zone("onyx") uassert.Equal(t, string(Pending), string(z.Status)) uassert.Equal(t, alice.String(), z.EditedBy.String()) uassert.Equal(t, int64(2), z.EditedAt) // Removed and proposed again under the same slug: a new revision, never the old one. uassert.NoError(t, r.RemoveZone("onyx", rev(r, "onyx"))) uassert.NoError(t, r.Propose(alice, 4, "onyx", onyx())) uassert.ErrorContains(t, r.ReviewZone("onyx", Approved, read, curator, 5, ""), "changed since you read it") uassert.NoError(t, r.ReviewZone("onyx", Approved, rev(r, "onyx"), curator, 5, "")) // Verifying an endpoint binds the same way: to the chain id it was checked against. id, err := r.Register(alice, 6, "onyx", RPC, "https://a.example.com", "") uassert.NoError(t, err) checked := zr(r, id) moved := onyx() moved.ChainID = "onyx-2" uassert.NoError(t, r.Edit("onyx", rev(r, "onyx"), moved, curator, 7, "relaunch")) uassert.ErrorContains(t, r.ReviewEndpoint(id, Verified, checked, erev(r, id), curator, 8, ""), "changed since you checked it against it") // A revision from the future is as wrong as a past one. uassert.ErrorContains(t, r.ReviewEndpoint(id, Verified, zr(r, id)+1, erev(r, id), curator, 8, ""), "changed since") uassert.ErrorContains(t, r.ReviewEndpoint(id, Verified, zr(r, id), erev(r, id)+1, curator, 8, ""), "changed since") uassert.NoError(t, r.ReviewEndpoint(id, Verified, zr(r, id), erev(r, id), curator, 8, "")) // Every verdict binds to the endpoint as read, too: a verify written before // another curator's flag fails rather than silently reversing it. read = erev(r, id) uassert.NoError(t, r.ReviewEndpoint(id, Flagged, zr(r, id), read, curator, 9, "down")) uassert.ErrorContains(t, r.ReviewEndpoint(id, Unverified, zr(r, id), read, alice, 10, ""), "changed since you checked it") // The same verdict again, with a new reason, restates it. uassert.NoError(t, r.ReviewEndpoint(id, Flagged, zr(r, id), erev(r, id), curator, 11, "down since block 9")) uassert.ErrorContains(t, r.ReviewEndpoint(id, Flagged, zr(r, id), erev(r, id), curator, 12, "down since block 9"), "restating it needs a new reason") } func TestPendingEditTakesNoReason(t *testing.T) { r := NewRegistry() uassert.NoError(t, r.Propose(alice, 1, "onyx", onyx())) uassert.ErrorContains(t, r.Edit("onyx", rev(r, "onyx"), onyx(), alice, 2, "fixed a typo"), "takes no reason") uassert.NoError(t, r.ReviewZone("onyx", Approved, rev(r, "onyx"), curator, 3, "")) uassert.ErrorContains(t, r.Edit("onyx", rev(r, "onyx"), onyx(), curator, 4, "\u200b"), "invisible") uassert.ErrorContains(t, r.Edit("onyx", rev(r, "onyx"), onyx(), curator, 4, ""), "needs a reason") uassert.ErrorContains(t, r.Edit("onyx", rev(r, "onyx"), onyx(), curator, 4, "no change"), "changes nothing") in := onyx() in.Title = "Onyx testnet" uassert.NoError(t, r.Edit("onyx", rev(r, "onyx"), in, curator, 4, "retitled")) } // Rejected and retired zones are kept for the record, each up to a cap of its // own, and neither counts against the live registry: nothing a proposer or a // curator does, and no amount of time, fills it for good. func TestRecordsNeverFillTheRegistry(t *testing.T) { r := NewRegistry() for i := 0; i < MaxRejected+3; i++ { slug := "rej" + strconv.Itoa(i) uassert.NoError(t, r.Propose(testAddr(i), 1, slug, onyx())) uassert.NoError(t, r.ReviewZone(slug, Rejected, rev(r, slug), curator, 2, "spam")) } uassert.Equal(t, MaxRejected, r.ZoneCount(Rejected)) // The three that entered the rejected state first made room. _, ok := r.Zone("rej0") uassert.False(t, ok) _, ok = r.Zone("rej3") uassert.True(t, ok) uassert.Equal(t, 0, r.Live()) for i := 0; i < MaxRetired+2; i++ { slug := "ret" + strconv.Itoa(i) uassert.NoError(t, r.Propose(curator, 3, slug, onyx())) uassert.NoError(t, r.ReviewZone(slug, Approved, rev(r, slug), curator, 3, "")) uassert.NoError(t, r.ReviewZone(slug, Retired, rev(r, slug), curator, 4, "stopped")) } uassert.Equal(t, MaxRetired, r.ZoneCount(Retired)) _, ok = r.Zone("ret0") uassert.False(t, ok) uassert.Equal(t, 0, r.Live()) uassert.Equal(t, MaxRejected+MaxRetired, r.Len()) // The live registry is untouched by all of it. uassert.NoError(t, r.Propose(bob, 5, "honest", onyx())) uassert.Equal(t, 1, r.Live()) } // An evicted zone takes its endpoints and every index entry with it, so its // slug and its addresses can be used again. func TestEvictionUnwindsEverything(t *testing.T) { r := NewRegistry() uassert.NoError(t, r.Propose(alice, 1, "first", onyx())) _, err := r.Register(alice, 1, "first", RPC, "https://f.example.com", "") uassert.NoError(t, err) fl, err := r.Register(alice, 1, "first", RPC, "https://flagged.example.com", "") uassert.NoError(t, err) uassert.NoError(t, r.ReviewEndpoint(fl, Flagged, zr(r, fl), erev(r, fl), curator, 1, "spam")) uassert.NoError(t, r.ReviewZone("first", Rejected, rev(r, "first"), curator, 2, "dup")) for i := 0; i < MaxRejected; i++ { slug := "rej" + strconv.Itoa(i) uassert.NoError(t, r.Propose(testAddr(i), 3, slug, onyx())) uassert.NoError(t, r.ReviewZone(slug, Rejected, rev(r, slug), curator, 4, "spam")) } _, ok := r.Zone("first") uassert.False(t, ok) _, total := r.Count("first") uassert.Equal(t, 0, total) uassert.Equal(t, 0, r.OwnerCount("first", alice)) uassert.Equal(t, 0, r.EndpointCount("first", RPC)) uassert.NoError(t, r.Propose(alice, 5, "first", onyx())) // Neither the review queue nor the flag count of the evicted zone carries // over to the slug proposed again. uassert.Equal(t, 0, r.Awaiting("first")) _, err = r.Register(alice, 5, "first", RPC, "https://f.example.com", "") uassert.NoError(t, err) uassert.Equal(t, 1, r.Awaiting("first")) } // Approving a rejected or retired zone back into the live registry respects // its cap. func TestReviveRespectsTheLiveCap(t *testing.T) { r := NewRegistry() uassert.NoError(t, r.Propose(alice, 1, "back", onyx())) uassert.NoError(t, r.ReviewZone("back", Rejected, rev(r, "back"), curator, 1, "not yet")) for i := 0; i < MaxZones; i++ { slug := "z" + strconv.Itoa(i) uassert.NoError(t, r.ProposeExempt(curator, 2, slug, onyx())) uassert.NoError(t, r.ReviewZone(slug, Approved, rev(r, slug), curator, 2, "")) } uassert.Equal(t, MaxZones, r.Live()) uassert.ErrorContains(t, r.ReviewZone("back", Approved, rev(r, "back"), curator, 3, ""), "full at") uassert.ErrorContains(t, r.Propose(bob, 3, "more", onyx()), "full at") uassert.NoError(t, r.ReviewZone("z0", Retired, rev(r, "z0"), curator, 3, "stopped")) uassert.NoError(t, r.ReviewZone("back", Approved, rev(r, "back"), curator, 4, "")) } func TestUppercaseAddressesAreRefused(t *testing.T) { r := NewRegistry() upper := address(strings.ToUpper(alice.String())) uassert.True(t, upper.IsValid(), "bech32 itself decodes the uppercase form") uassert.False(t, ValidAddress(upper)) uassert.ErrorContains(t, r.Propose(upper, 1, "onyx", onyx()), "lowercase") uassert.NoError(t, r.Propose(alice, 1, "onyx", onyx())) _, err := r.Register(upper, 1, "onyx", RPC, "https://a.example.com", "") uassert.ErrorContains(t, err, "lowercase") uassert.ErrorContains(t, r.ReviewZone("onyx", Approved, rev(r, "onyx"), upper, 2, ""), "lowercase") } // A peer is stored lowercased whole, so the listed form is the one tm2 dials, // and two spellings of one host are one peer. func TestPeerStoredLowercase(t *testing.T) { r := NewRegistry() uassert.NoError(t, r.Propose(alice, 1, "onyx", onyx())) id, err := r.Register(alice, 1, "onyx", Peer, nodeID+"@SEED-1.Onyx.example.com:26656", "") uassert.NoError(t, err) e, _ := r.Endpoint(id) uassert.Equal(t, nodeID+"@seed-1.onyx.example.com:26656", e.Address) _, err = r.Register(alice, 1, "onyx", Peer, nodeID+"@seed-1.onyx.example.com:26656", "") uassert.ErrorContains(t, err, "already lists that peer") got, ok := r.EndpointByAddress("onyx", Peer, nodeID+"@Seed-1.onyx.example.com:26656") uassert.True(t, ok) uassert.Equal(t, id, got.ID) } // Rejecting sends what was verified on the proposal back to unverified: a // rejected zone was never vouched for, so nothing on it reads as checked. func TestRejectUnverifies(t *testing.T) { r := NewRegistry() uassert.NoError(t, r.Propose(alice, 1, "onyx", onyx())) id, err := r.Register(alice, 1, "onyx", RPC, "https://a.example.com", "") uassert.NoError(t, err) uassert.NoError(t, r.ReviewEndpoint(id, Verified, zr(r, id), erev(r, id), curator, 2, "")) uassert.NoError(t, r.ReviewZone("onyx", Rejected, rev(r, "onyx"), curator, 3, "not a gno chain")) e, _ := r.Endpoint(id) uassert.Equal(t, string(Unverified), string(e.Status)) uassert.Equal(t, ZoneRejected, e.Reason) uassert.Equal(t, curator.String(), e.ReviewedBy.String()) v, _ := r.Count("onyx") uassert.Equal(t, 0, v) } // Eviction goes by when a zone entered its state, not when it was proposed: a // long-lived network retired today is not the next record to go. func TestEvictionIsByEntryOrder(t *testing.T) { r := NewRegistry() uassert.NoError(t, r.Propose(curator, 1, "mainnet", onyx())) // proposed first uassert.NoError(t, r.ReviewZone("mainnet", Approved, rev(r, "mainnet"), curator, 1, "")) for i := 0; i < MaxRetired; i++ { slug := "ret" + strconv.Itoa(i) uassert.NoError(t, r.Propose(curator, 2, slug, onyx())) uassert.NoError(t, r.ReviewZone(slug, Approved, rev(r, slug), curator, 2, "")) uassert.NoError(t, r.ReviewZone(slug, Retired, rev(r, slug), curator, 3, "stopped")) } // Retiring mainnet now drops ret0, the zone retired longest ago. uassert.NoError(t, r.ReviewZone("mainnet", Retired, rev(r, "mainnet"), curator, 4, "end of an era")) _, ok := r.Zone("ret0") uassert.False(t, ok) // And the next retirement drops ret1, not mainnet. uassert.NoError(t, r.Propose(curator, 5, "next", onyx())) uassert.NoError(t, r.ReviewZone("next", Approved, rev(r, "next"), curator, 5, "")) uassert.NoError(t, r.ReviewZone("next", Retired, rev(r, "next"), curator, 6, "stopped")) _, ok = r.Zone("mainnet") uassert.True(t, ok) _, ok = r.Zone("ret1") uassert.False(t, ok) } // Every decision on a zone names the revision it was made on. func TestEveryZoneDecisionIsRevisionBound(t *testing.T) { r := NewRegistry() uassert.NoError(t, r.Propose(alice, 1, "onyx", onyx())) old := rev(r, "onyx") edited := onyx() edited.Title = "Onyx, edited" uassert.NoError(t, r.Edit("onyx", old, edited, alice, 2, "")) uassert.ErrorContains(t, r.Edit("onyx", old, onyx(), curator, 3, ""), "changed since you read it") uassert.ErrorContains(t, r.ReviewZone("onyx", Rejected, old, curator, 3, "spam"), "changed since you read it") uassert.ErrorContains(t, r.RemoveZone("onyx", old), "changed since you read it") uassert.NoError(t, r.ReviewZone("onyx", Approved, rev(r, "onyx"), curator, 3, "")) uassert.ErrorContains(t, r.ReviewZone("onyx", Retired, old, curator, 4, "gone"), "changed since you read it") uassert.NoError(t, r.ReviewZone("onyx", Retired, rev(r, "onyx"), curator, 4, "gone")) } func TestCanonicalHostAndTextEdgeCases(t *testing.T) { r := NewRegistry() uassert.NoError(t, r.Propose(alice, 1, "onyx", onyx())) // A peer's host loses its terminal dot, so the two spellings are one peer. _, err := r.Register(alice, 1, "onyx", Peer, nodeID+"@seed-1.onyx.example.com:26656", "") uassert.NoError(t, err) _, err = r.Register(alice, 1, "onyx", Peer, nodeID+"@seed-1.onyx.example.com.:26656", "") uassert.ErrorContains(t, err, "already lists that peer") // An IPv4 host with a terminal dot is refused: Go's dialer cannot use it. uassert.ErrorContains(t, ValidateEndpoint(RPC, "http://1.2.3.4.:26657"), "DNS name or an IPv4") uassert.ErrorContains(t, ValidateEndpoint(Peer, nodeID+"@1.2.3.4.:26656"), "DNS name or an IPv4") // A loopback or private host is a local zone's only. _, err = r.Register(alice, 1, "onyx", RPC, "http://127.0.0.1:26657", "") uassert.ErrorContains(t, err, "private or special-use") _, err = r.Register(alice, 1, "onyx", Peer, nodeID+"@10.1.2.3:26656", "") uassert.ErrorContains(t, err, "private or special-use") local := onyx() local.Kind, local.RPCURL = Local, "http://127.0.0.1:26657" uassert.NoError(t, r.Propose(alice, 1, "mine", local)) _, err = r.Register(alice, 1, "mine", Peer, nodeID+"@127.0.0.1:26656", "") uassert.NoError(t, err) // Equivalent URL spellings are one endpoint. for _, pair := range [][2]string{ {"https://h.example.com/path", "https://h.example.com/path?"}, {"https://h.example.com?a=1", "https://h.example.com/?a=1"}, {"https://h.example.com/%2F", "https://h.example.com/%2f"}, } { uassert.Equal(t, Canonical(Indexer, pair[0]), Canonical(Indexer, pair[1]), pair[0]) } uassert.ErrorContains(t, ValidateEndpoint(Indexer, "https://h.example.com/%7e"), "needs no escaping") uassert.ErrorContains(t, ValidateEndpoint(Indexer, "https://h.example.com/%41"), "needs no escaping") // Look-alike badges and characters that draw nothing. in := onyx() for _, title := range []string{"✔ official", "☑ mainnet", "\U0001D159", "main\U000E0100net", "x\ue000", "\u180b", "\u0378"} { in.Title = title uassert.Error(t, ValidateInfo(in), title) } } // The per-proposer and per-registrant caps are counts, and they follow every // way a zone or an endpoint leaves. func TestCountersFollowRemovals(t *testing.T) { r := NewRegistry() uassert.NoError(t, r.Propose(alice, 1, "onyx", onyx())) a, _ := r.Register(alice, 1, "onyx", RPC, "https://a.example.com", "") _, _ = r.Register(alice, 1, "onyx", RPC, "https://b.example.com", "") uassert.Equal(t, 2, r.OwnerCount("onyx", alice)) uassert.NoError(t, r.RemoveEndpoint(a, erev(r, a))) uassert.Equal(t, 1, r.OwnerCount("onyx", alice)) uassert.NoError(t, r.RemoveZone("onyx", rev(r, "onyx"))) uassert.Equal(t, 0, r.OwnerCount("onyx", alice)) for i := 0; i < MaxPendingPerProposer; i++ { uassert.NoError(t, r.Propose(alice, 2, "p"+strconv.Itoa(i), onyx())) } uassert.ErrorContains(t, r.Propose(alice, 2, "over", onyx()), "pending proposals") uassert.NoError(t, r.ReviewZone("p0", Approved, rev(r, "p0"), curator, 3, "")) uassert.NoError(t, r.Propose(alice, 3, "over", onyx())) } func TestSoleApproved(t *testing.T) { r := NewRegistry() uassert.NoError(t, r.Propose(curator, 1, "za", onyx())) _, ok := r.SoleApproved("onyx-1") uassert.False(t, ok, "pending is not approved") uassert.NoError(t, r.ReviewZone("za", Approved, rev(r, "za"), curator, 1, "")) z, ok := r.SoleApproved("onyx-1") uassert.True(t, ok) uassert.Equal(t, "za", z.Slug) uassert.NoError(t, r.Propose(curator, 1, "zb", onyx())) uassert.NoError(t, r.ReviewZone("zb", Approved, rev(r, "zb"), curator, 1, "")) _, ok = r.SoleApproved("onyx-1") uassert.False(t, ok, "two approved zones share it: no guess") } // A status change bumps the revision too: an approval prepared before a // colleague's rejection fails instead of quietly reversing it. func TestStatusChangeBumpsTheRevision(t *testing.T) { r := NewRegistry() uassert.NoError(t, r.Propose(alice, 1, "onyx", onyx())) read := rev(r, "onyx") uassert.NoError(t, r.ReviewZone("onyx", Rejected, read, bob, 2, "phishing RPC")) uassert.ErrorContains(t, r.ReviewZone("onyx", Approved, read, curator, 3, ""), "changed since you read it") z, _ := r.Zone("onyx") uassert.Equal(t, "phishing RPC", z.Reason) } // Leaving the local kind drops the private endpoints in the same edit, so // nobody can block the edit by registering one again after each removal. func TestLeavingLocalDropsPrivateEndpoints(t *testing.T) { r := NewRegistry() local := onyx() local.Kind, local.RPCURL = Local, "http://127.0.0.1:26657" uassert.NoError(t, r.Propose(alice, 1, "mine", local)) priv, err := r.Register(alice, 1, "mine", Peer, nodeID+"@10.0.0.5:26656", "") uassert.NoError(t, err) flagged, err := r.Register(bob, 1, "mine", RPC, "http://localhost:1", "") uassert.NoError(t, err) uassert.NoError(t, r.ReviewEndpoint(flagged, Flagged, zr(r, flagged), erev(r, flagged), curator, 1, "squatting")) pub, err := r.Register(alice, 1, "mine", RPC, "https://rpc.mine.example.com", "") uassert.NoError(t, err) // A ruled one is a record: the edit refuses to drop it unseen, and a // curator removes it first. uassert.ErrorContains(t, r.Edit("mine", rev(r, "mine"), onyx(), alice, 2, ""), "which a curator ruled on") uassert.NoError(t, r.RemoveEndpoint(flagged, erev(r, flagged))) uassert.NoError(t, r.Edit("mine", rev(r, "mine"), onyx(), alice, 2, "")) for _, id := range []int64{priv, flagged} { _, ok := r.Endpoint(id) uassert.False(t, ok, "a private endpoint is dropped") } _, ok := r.Endpoint(pub) uassert.True(t, ok, "a public one stays") uassert.Equal(t, 1, r.EndpointCount("mine", "")) uassert.Equal(t, 0, r.OwnerCount("mine", bob)) uassert.Equal(t, 1, r.Awaiting("mine")) } // A curator's chain-id edit of a pending zone names the curator on the resets; // only the proposer's own edit records nobody. func TestResetNamesACuratorEditor(t *testing.T) { r := NewRegistry() uassert.NoError(t, r.Propose(alice, 1, "onyx", onyx())) id, _ := r.Register(alice, 1, "onyx", RPC, "https://a.example.com", "") uassert.NoError(t, r.ReviewEndpoint(id, Verified, zr(r, id), erev(r, id), curator, 2, "")) moved := onyx() moved.ChainID = "onyx-2" uassert.NoError(t, r.Edit("onyx", rev(r, "onyx"), moved, curator, 3, "")) e, _ := r.Endpoint(id) uassert.Equal(t, curator.String(), e.ReviewedBy.String()) } func TestPrivateHostsAndURLForms(t *testing.T) { for _, h := range []string{"validator", "node", "printer.local", "metadata.google.internal", "router.home.arpa", "foo.localdomain", "a.localhost", "192.0.0.1", "198.18.0.1", "198.19.255.255", "224.0.0.1", "255.255.255.255", "127.1", "0x7f.1", "2130706433", "rpc.test", "node.lan", "nas.home", "dc.corp", "wiki.intranet", "box.private", "x.onion", "y.alt", "192.0.2.1", "198.51.100.7", "203.0.113.255", "gno.example", "x.invalid", "rpc.mail", "ipv4only.arpa", "gnoland.default.service.arpa", "x.arpa", "100.127.255.255", "172.16.0.1", "172.31.255.255", "svc.service.consul", "node.lxd", "x.docker", "y.localnet"} { uassert.True(t, IsPrivateHost(h), h) } for _, h := range []string{"rpc.gno.land", "1.1.1.1", "198.20.0.1", "172.32.0.1", "172.15.255.255", "gno.land.", "100.128.0.1", "a.latest", "my.salt", "192.0.3.1", "198.51.101.1", "203.0.114.1", "example.com", "gmail.com", "arpa.example.com"} { uassert.False(t, IsPrivateHost(h), h) } // tcp:// and http:// are one rpc endpoint, the way gnokey dials them. uassert.Equal(t, Canonical(RPC, "tcp://h.example.com:26657"), Canonical(RPC, "http://h.example.com:26657")) uassert.Equal(t, Canonical(RPC, "tcp://h.example.com:80"), Canonical(RPC, "http://h.example.com")) // An empty query's ? goes; a query ending in ? keeps it. uassert.Equal(t, "https://h.example.com/p?a?", Canonical(Indexer, "https://h.example.com/p?a?")) // . and .. path segments are another spelling: refused. for _, u := range []string{"https://h.example.com/.", "https://h.example.com/./x", "https://h.example.com/a/..", "https://h.example.com/../a"} { uassert.ErrorContains(t, ValidateEndpoint(Indexer, u), "path segment", u) } uassert.NoError(t, ValidateEndpoint(Indexer, "https://h.example.com/a.b/..c/x.")) in := onyx() for _, title := range []string{"⌛ pending", "❎ rejected", "\U0001F6D1 stop", "☒ no"} { in.Title = title uassert.ErrorContains(t, ValidateInfo(in), "status glyph", title) } } // A copied Registry value is the same registry: it shares the revision // counter with the zones it shares, so no revision is handed out twice. With // the counter inline, an edit through the copy and then one through the // original would both produce the same revision number, and a decision made on // the first content would pass against the second. func TestCopiedRegistryIsTheSameRegistry(t *testing.T) { r1 := NewRegistry() uassert.NoError(t, r1.Propose(alice, 1, "onyx", onyx())) r2 := *r1 in := onyx() in.Title = "via the copy" uassert.NoError(t, r2.Edit("onyx", rev(r1, "onyx"), in, alice, 2, "")) seen := rev(r1, "onyx") // what a curator read: the copy's content in.Title = "via the original" uassert.NoError(t, r1.Edit("onyx", seen, in, alice, 3, "")) uassert.True(t, rev(r1, "onyx") != seen, "a new content, a new revision") uassert.ErrorContains(t, r1.ReviewZone("onyx", Approved, seen, curator, 4, ""), "changed since you read it") } // Editing a local zone that stays local drops nothing, and every Info URL is // checked for a private host, not only the main RPC. func TestOnlyLeavingLocalDrops(t *testing.T) { r := NewRegistry() local := onyx() local.Kind, local.RPCURL = Local, "http://127.0.0.1:26657" uassert.NoError(t, r.Propose(alice, 1, "mine", local)) id, err := r.Register(alice, 1, "mine", Peer, nodeID+"@10.0.0.5:26656", "") uassert.NoError(t, err) local.Title = "Mine, renamed" uassert.NoError(t, r.Edit("mine", rev(r, "mine"), local, alice, 2, "")) _, ok := r.Endpoint(id) uassert.True(t, ok, "a local zone keeps its private endpoints") for _, set := range []func(*Info){ func(in *Info) { in.GnowebURL = "http://192.168.1.2" }, func(in *Info) { in.GenesisURL = "https://files.lan/genesis.json" }, } { in := onyx() set(&in) uassert.ErrorContains(t, ValidateInfo(in), "private or special-use") } } // Any edit to an approved zone is a review on record, so a chain-id reset it // causes names its editor, whoever that is; only a pending zone's own // proposer resets as nobody. func TestApprovedEditResetNamesTheEditor(t *testing.T) { r := NewRegistry() uassert.NoError(t, r.Propose(alice, 1, "onyx", onyx())) uassert.NoError(t, r.ReviewZone("onyx", Approved, rev(r, "onyx"), curator, 2, "")) id, _ := r.Register(bob, 3, "onyx", RPC, "https://a.example.com", "") uassert.NoError(t, r.ReviewEndpoint(id, Verified, zr(r, id), erev(r, id), curator, 4, "")) moved := onyx() moved.ChainID = "onyx-2" uassert.NoError(t, r.Edit("onyx", rev(r, "onyx"), moved, alice, 5, "relaunched")) e, _ := r.Endpoint(id) uassert.Equal(t, alice.String(), e.ReviewedBy.String()) } // A curator's exemption is from the review queue, never from the live cap. func TestExemptStillMeetsTheLiveCap(t *testing.T) { r := NewRegistry() for i := 0; i < MaxZones; i++ { slug := "z" + strconv.Itoa(i) uassert.NoError(t, r.ProposeExempt(curator, 1, slug, onyx())) uassert.NoError(t, r.ReviewZone(slug, Approved, rev(r, slug), curator, 1, "")) } uassert.ErrorContains(t, r.ProposeExempt(curator, 2, "one-more", onyx()), "the registry is full") } // The URL forms each kind dials: a tcp:// rpc is host and port only, an // indexer may be a websocket, and IsPrivateHost fails closed on anything but a // bare host. func TestURLFormsPerKind(t *testing.T) { uassert.ErrorContains(t, ValidateEndpoint(RPC, "tcp://rpc.example.com:26657/websocket"), "host and port only") uassert.ErrorContains(t, ValidateEndpoint(RPC, "tcp://rpc.example.com:26657?x=1"), "host and port only") uassert.NoError(t, ValidateEndpoint(RPC, "tcp://rpc.example.com:26657")) uassert.NoError(t, ValidateEndpoint(RPC, "wss://rpc.example.com/websocket")) uassert.NoError(t, ValidateEndpoint(Indexer, "wss://indexer.example.com/graphql/query")) uassert.Error(t, ValidateEndpoint(Faucet, "wss://faucet.example.com")) for _, h := range []string{"127.0.0.1:26657", "10.0.0.1/", "foo.local:80", "a b", "192.88.99.1"} { uassert.True(t, IsPrivateHost(h), h) } } // A rejection or a retirement is restated with a new reason, the only way to // correct one, without a second eviction or reset. func TestARejectionReasonCanBeRestated(t *testing.T) { r := NewRegistry() uassert.NoError(t, r.Propose(alice, 1, "onyx", onyx())) uassert.NoError(t, r.ReviewZone("onyx", Rejected, rev(r, "onyx"), curator, 2, "wrong netwrok")) entered := func() int64 { z, _ := r.Zone("onyx"); return z.Entered } before := entered() uassert.NoError(t, r.ReviewZone("onyx", Rejected, rev(r, "onyx"), bob, 3, "wrong network")) z, _ := r.Zone("onyx") uassert.Equal(t, "wrong network", z.Reason) uassert.Equal(t, bob.String(), z.ReviewedBy.String()) uassert.Equal(t, before, entered(), "a restatement is not a new entry") uassert.ErrorContains(t, r.ReviewZone("onyx", Rejected, rev(r, "onyx"), bob, 4, "wrong network"), "already rejected") } // Flagging a verified endpoint puts it back in the unchecked index, so counts // and a later reset see it as what it is. func TestFlaggingAVerifiedEndpointUnchecksIt(t *testing.T) { r := NewRegistry() uassert.NoError(t, r.Propose(alice, 1, "onyx", onyx())) uassert.NoError(t, r.ReviewZone("onyx", Approved, rev(r, "onyx"), curator, 1, "")) id, _ := r.Register(bob, 2, "onyx", RPC, "https://a.example.com", "") uassert.NoError(t, r.ReviewEndpoint(id, Verified, zr(r, id), erev(r, id), curator, 3, "")) uassert.NoError(t, r.ReviewEndpoint(id, Flagged, zr(r, id), erev(r, id), carol, 4, "down")) v, total := r.Count("onyx") uassert.Equal(t, 0, v) uassert.Equal(t, 1, total) uassert.Equal(t, 0, r.Awaiting("onyx")) // A retirement then keeps the flag: it resets only what is verified. uassert.NoError(t, r.ReviewZone("onyx", Retired, rev(r, "onyx"), curator, 5, "shut down")) e, _ := r.Endpoint(id) uassert.Equal(t, string(Flagged), string(e.Status)) // A restated verdict names its new reviewer. uassert.NoError(t, r.ReviewEndpoint(id, Flagged, zr(r, id), erev(r, id), curator, 6, "down for good")) e, _ = r.Endpoint(id) uassert.Equal(t, curator.String(), e.ReviewedBy.String()) } // A reset moves the endpoint's revision, so a verdict written against the // verified endpoint fails after a retirement reset it. func TestAResetMovesTheEndpointRevision(t *testing.T) { r := NewRegistry() uassert.NoError(t, r.Propose(alice, 1, "onyx", onyx())) uassert.NoError(t, r.ReviewZone("onyx", Approved, rev(r, "onyx"), curator, 1, "")) id, _ := r.Register(bob, 2, "onyx", RPC, "https://a.example.com", "") uassert.NoError(t, r.ReviewEndpoint(id, Verified, zr(r, id), erev(r, id), curator, 3, "")) read := erev(r, id) uassert.NoError(t, r.ReviewZone("onyx", Retired, rev(r, "onyx"), curator, 4, "shut down")) uassert.ErrorContains(t, r.ReviewEndpoint(id, Flagged, zr(r, id), read, carol, 5, "x"), "changed since") } // The package README's example, verbatim but for must and the names: it is // the first thing an importer copies, so it is run. func TestTheReadmeExampleRuns(t *testing.T) { proposer, height := alice, int64(1) r := NewRegistry() uassert.NoError(t, r.Propose(proposer, height, "onyx", Info{ChainID: "onyx-1", Title: "Onyx", Kind: Testnet, RPCURL: "https://rpc.onyx.testnets.gno.land"})) z, _ := r.Zone("onyx") uassert.NoError(t, r.ReviewZone("onyx", Approved, z.Revision, curator, height, "")) z, _ = r.Zone("onyx") id, err := r.Register(proposer, height, "onyx", Peer, "g1x5mlj5ava0dw9vkf4j6admjlzswm6f06p44krn@seed-1.onyx.testnets.gno.land:26656", "gno core") uassert.NoError(t, err) e, _ := r.Endpoint(id) uassert.NoError(t, r.ReviewEndpoint(id, Verified, z.Revision, e.Revision, curator, height, "answers onyx-1")) uassert.Equal(t, 1, len(r.Endpoints(EndpointFilter{Zone: "onyx", Kind: Peer, Status: Verified}))) } // A verification restated with a new reason names its new reviewer, and a // zone removed and proposed again starts with no private endpoints on record. func TestRestatedVerifyAndARemovedZonesPrivateIndex(t *testing.T) { r := NewRegistry() uassert.NoError(t, r.Propose(alice, 1, "onyx", onyx())) id, _ := r.Register(alice, 1, "onyx", RPC, "https://a.example.com", "") uassert.NoError(t, r.ReviewEndpoint(id, Verified, zr(r, id), erev(r, id), curator, 2, "answers")) uassert.NoError(t, r.ReviewEndpoint(id, Verified, zr(r, id), erev(r, id), carol, 3, "answers, re-probed")) e, _ := r.Endpoint(id) uassert.Equal(t, carol.String(), e.ReviewedBy.String()) local := onyx() local.Kind, local.RPCURL = Local, "http://127.0.0.1:26657" uassert.NoError(t, r.Propose(alice, 4, "lab", local)) _, err := r.Register(alice, 4, "lab", RPC, "http://10.0.0.1:26657", "") uassert.NoError(t, err) uassert.NoError(t, r.RemoveZone("lab", rev(r, "lab"))) uassert.NoError(t, r.Propose(alice, 5, "lab", local)) uassert.Equal(t, 0, len(r.PrivateEndpoints("lab"))) uassert.NoError(t, r.Edit("lab", rev(r, "lab"), onyx(), alice, 6, "")) } // A zone decision naming a revision from the future fails as a stale one does. func TestAFutureRevisionIsRefused(t *testing.T) { r := NewRegistry() uassert.NoError(t, r.Propose(alice, 1, "onyx", onyx())) uassert.ErrorContains(t, r.ReviewZone("onyx", Approved, rev(r, "onyx")+1, curator, 2, ""), "changed since you read it") } // Restating: any review state with a new visible reason, bumping the // revision; never with an empty or the same reason. func TestRestatementRules(t *testing.T) { r := NewRegistry() uassert.NoError(t, r.Propose(alice, 1, "onyx", onyx())) uassert.NoError(t, r.ReviewZone("onyx", Approved, rev(r, "onyx"), curator, 2, "answers onyx-1")) read := rev(r, "onyx") uassert.NoError(t, r.ReviewZone("onyx", Approved, read, carol, 3, "answers onyx-1, re-probed")) uassert.True(t, rev(r, "onyx") > read, "a restatement moves the revision") uassert.ErrorContains(t, r.ReviewZone("onyx", Approved, rev(r, "onyx"), carol, 4, " "), "already approved") id, _ := r.Register(bob, 4, "onyx", RPC, "https://a.example.com", "") uassert.NoError(t, r.ReviewEndpoint(id, Flagged, zr(r, id), erev(r, id), curator, 4, "down")) uassert.ErrorContains(t, r.ReviewEndpoint(id, Flagged, zr(r, id), erev(r, id), curator, 4, ""), "needs a new reason") uassert.NoError(t, r.ReviewZone("onyx", Retired, rev(r, "onyx"), curator, 5, "shut down")) uassert.NoError(t, r.ReviewZone("onyx", Retired, rev(r, "onyx"), carol, 6, "shut down 2026-10-01")) z, _ := r.Zone("onyx") uassert.Equal(t, "shut down 2026-10-01", z.Reason) } // What a URL is stored as: scheme and host lowercased in ASCII only, the path // and query as typed; a letter that lowercases into ASCII is refused, not // folded. A tcp:// rpc path is refused whatever the scheme's case. func TestStoredURLForm(t *testing.T) { r := NewRegistry() in := onyx() in.GenesisURL = "HTTPS://Files.Example.com/G?Q=1" uassert.NoError(t, r.Propose(alice, 1, "onyx", in)) z, _ := r.Zone("onyx") uassert.Equal(t, "https://files.example.com/G?Q=1", z.GenesisURL) id, err := r.Register(alice, 1, "onyx", Indexer, "HTTPS://Idx.Example.com?Q=1", "") uassert.NoError(t, err) e, _ := r.Endpoint(id) uassert.Equal(t, "https://idx.example.com?Q=1", e.Address) for _, u := range []string{"https://Key.example.com", "https://rpc.İnfo.example.com"} { bad := onyx() bad.RPCURL = u uassert.Error(t, r.Propose(bob, 2, "x"+strconv.Itoa(len(u)), bad), u) } uassert.ErrorContains(t, ValidateEndpoint(RPC, "TCP://h.example.com:26657/websocket"), "host and port only") uassert.False(t, IsPrivateHost("RPC.GNO.LAND")) uassert.ErrorContains(t, ValidateEndpoint(RPC, "https://café.example.com"), "'é'") } // A curator's leave-local edit is refused while a private endpoint carries a // ruling of any kind: a reset's reason, a reasonless unverify, a verification; // a curator who proposed the zone included. func TestLeavingLocalRefusesEveryRuling(t *testing.T) { setup := func() (*Registry, int64) { r := NewRegistry() local := onyx() local.Kind, local.RPCURL = Local, "http://127.0.0.1:26657" uassert.NoError(t, r.Propose(curator, 1, "lab", local)) id, err := r.Register(curator, 1, "lab", RPC, "http://10.0.0.1:26657", "") uassert.NoError(t, err) return r, id } r, id := setup() uassert.NoError(t, r.ReviewEndpoint(id, Verified, zr(r, id), erev(r, id), carol, 2, "")) uassert.ErrorContains(t, r.Edit("lab", rev(r, "lab"), onyx(), curator, 3, ""), "is verified") r, id = setup() uassert.NoError(t, r.ReviewEndpoint(id, Flagged, zr(r, id), erev(r, id), carol, 2, "x")) uassert.NoError(t, r.ReviewEndpoint(id, Unverified, zr(r, id), erev(r, id), carol, 3, "")) uassert.ErrorContains(t, r.Edit("lab", rev(r, "lab"), onyx(), curator, 4, ""), "a curator ruled on") r, id = setup() uassert.NoError(t, r.ReviewEndpoint(id, Verified, zr(r, id), erev(r, id), carol, 2, "")) moved := onyx() moved.Kind, moved.RPCURL, moved.ChainID = Local, "http://127.0.0.1:26657", "lab-2" uassert.NoError(t, r.Edit("lab", rev(r, "lab"), moved, curator, 3, "")) // the proposer's reset: no reviewer, a reason e, _ := r.Endpoint(id) uassert.Equal(t, "", e.ReviewedBy.String()) uassert.ErrorContains(t, r.Edit("lab", rev(r, "lab"), onyx(), curator, 4, ""), "a curator ruled on") } // Clearable counts what a bulk clear may remove: registered through the gate, // never ruled on. A first verdict or a removal takes one out (a reset cannot: // it touches only verified endpoints); an exempt registration never counts. func TestTheClearableCount(t *testing.T) { r := NewRegistry() uassert.NoError(t, r.Propose(alice, 1, "onyx", onyx())) uassert.NoError(t, r.ReviewZone("onyx", Approved, rev(r, "onyx"), curator, 1, "")) a, _ := r.Register(bob, 2, "onyx", RPC, "https://a.example.com", "") b, _ := r.Register(bob, 2, "onyx", RPC, "https://b.example.com", "") c, _ := r.Register(bob, 2, "onyx", RPC, "https://c.example.com", "") x, _ := r.RegisterExempt(curator, 2, "onyx", RPC, "https://x.example.com", "") ex, _ := r.Endpoint(x) uassert.True(t, ex.Exempt) uassert.False(t, ex.Clearable()) uassert.Equal(t, 3, r.Clearable("onyx")) uassert.NoError(t, r.ReviewEndpoint(a, Verified, zr(r, a), erev(r, a), curator, 3, "")) uassert.Equal(t, 2, r.Clearable("onyx")) uassert.NoError(t, r.ReviewEndpoint(a, Flagged, zr(r, a), erev(r, a), curator, 3, "down")) uassert.Equal(t, 2, r.Clearable("onyx"), "a second verdict changes nothing") uassert.NoError(t, r.RemoveEndpoint(b, erev(r, b))) uassert.Equal(t, 1, r.Clearable("onyx")) moved := onyx() moved.ChainID = "onyx-2" uassert.NoError(t, r.Edit("onyx", rev(r, "onyx"), moved, curator, 4, "relaunch")) uassert.Equal(t, 1, r.Clearable("onyx"), "a reset touches only verified endpoints") _ = c } // One edit off the local kind drops at most MaxDropPerEdit endpoints. func TestLeavingLocalDropsAtMostTheCap(t *testing.T) { r := NewRegistry() local := onyx() local.Kind, local.RPCURL = Local, "http://127.0.0.1:26657" uassert.NoError(t, r.Propose(curator, 1, "lab", local)) for i := 0; i <= MaxDropPerEdit; i++ { _, err := r.RegisterExempt(curator, 1, "lab", RPC, "http://10.0.0."+strconv.Itoa(i%250+1)+":"+strconv.Itoa(26000+i), "") uassert.NoError(t, err) } uassert.ErrorContains(t, r.Edit("lab", rev(r, "lab"), onyx(), curator, 2, ""), "more than 64 in one edit") ids := r.PrivateEndpoints("lab") uassert.NoError(t, r.RemoveEndpoint(ids[0].ID, ids[0].Revision)) uassert.NoError(t, r.Edit("lab", rev(r, "lab"), onyx(), curator, 3, "")) } // The numbers the READMEs state, pinned: a change to one is a change to what // the docs promise. func TestTheDocumentedBounds(t *testing.T) { for name, got := range map[string]int{"MaxZones": MaxZones, "MaxPending": MaxPending, "MaxPendingPerProposer": MaxPendingPerProposer, "MaxRejected": MaxRejected, "MaxRetired": MaxRetired, "MaxEndpointsPerZone": MaxEndpointsPerZone, "MaxUnverifiedPerZone": MaxUnverifiedPerZone, "MaxEndpointsPerAddress": MaxEndpointsPerAddress, "MaxDropPerEdit": MaxDropPerEdit, "MaxTitleLen": MaxTitleLen, "MaxDescriptionLen": MaxDescriptionLen, "MaxLabelLen": MaxLabelLen, "MaxReasonLen": MaxReasonLen, "MaxURLLen": MaxURLLen} { want := map[string]int{"MaxZones": 256, "MaxPending": 64, "MaxPendingPerProposer": 4, "MaxRejected": 64, "MaxRetired": 128, "MaxEndpointsPerZone": 128, "MaxUnverifiedPerZone": 64, "MaxEndpointsPerAddress": 16, "MaxDropPerEdit": 64, "MaxTitleLen": 64, "MaxDescriptionLen": 512, "MaxLabelLen": 64, "MaxReasonLen": 280, "MaxURLLen": 256}[name] uassert.Equal(t, want, got, name) } } // Rules the other tests reach only in part. func TestValidationEdges(t *testing.T) { // A private host is refused on every kind but local. for _, k := range []Kind{Mainnet, Testnet, Devnet} { in := onyx() in.Kind, in.RPCURL = k, "http://10.0.0.1:26657" uassert.ErrorContains(t, ValidateInfo(in), "private or special-use", string(k)) } // The combining grapheme joiner draws nothing. in := onyx() in.Title = "On\u034fyx" uassert.ErrorContains(t, ValidateInfo(in), "invisible") // Needless escapes of unreserved characters, and an entity shape. for _, u := range []string{"https://h.example.com/a%5Fb", "https://h.example.com/a%2Db", "https://h.example.com/%2E%2E/x", "https://h.example.com/a&#38;b"} { uassert.Error(t, ValidateEndpoint(RPC, u), u) } // Dot segments are a path rule; a query may hold dots. uassert.NoError(t, ValidateEndpoint(Explorer, "https://h.example.com/p?x=../y")) // Default websocket ports are dropped like the http ones. uassert.Equal(t, Canonical(RPC, "wss://h.example.com/ws"), Canonical(RPC, "wss://h.example.com:443/ws")) uassert.Equal(t, Canonical(RPC, "ws://h.example.com/ws"), Canonical(RPC, "ws://h.example.com:80/ws")) } // What a caller types is trimmed before it is used: the kind, a label, a // reason; and a restatement of verify or unverify needs a new visible reason. func TestTrimsAndRestatementReasons(t *testing.T) { r := NewRegistry() in := onyx() in.Kind = " testnet " uassert.NoError(t, r.Propose(alice, 1, "onyx", in)) z, _ := r.Zone("onyx") uassert.Equal(t, string(Testnet), string(z.Kind)) uassert.NoError(t, r.ReviewZone("onyx", Approved, rev(r, "onyx"), curator, 1, "")) id, _ := r.Register(bob, 2, "onyx", RPC, "https://a.example.com", " bob's node ") e, _ := r.Endpoint(id) uassert.Equal(t, "bob's node", e.Label) uassert.NoError(t, r.ReviewEndpoint(id, Verified, zr(r, id), erev(r, id), curator, 3, " answers ")) e, _ = r.Endpoint(id) uassert.Equal(t, "answers", e.Reason) uassert.ErrorContains(t, r.ReviewEndpoint(id, Verified, zr(r, id), erev(r, id), curator, 4, " "), "needs a new reason") uassert.NoError(t, r.ReviewEndpoint(id, Unverified, zr(r, id), erev(r, id), curator, 5, "x")) uassert.ErrorContains(t, r.ReviewEndpoint(id, Unverified, zr(r, id), erev(r, id), curator, 6, ""), "needs a new reason") edited := onyx() edited.Title = "Onyx, edited" uassert.NoError(t, r.Edit("onyx", rev(r, "onyx"), edited, curator, 7, " retitled ")) z, _ = r.Zone("onyx") uassert.Equal(t, "retitled", z.Reason) uassert.Equal(t, rev(r, "onyx"), r.Revision(), "the last revision handed out") } // A zone removed and proposed again starts with nothing clearable. func TestARemovedZonesClearableCountIsGone(t *testing.T) { r := NewRegistry() uassert.NoError(t, r.Propose(alice, 1, "onyx", onyx())) _, err := r.Register(alice, 1, "onyx", RPC, "https://a.example.com", "") uassert.NoError(t, err) uassert.Equal(t, 1, r.Clearable("onyx")) uassert.NoError(t, r.RemoveZone("onyx", rev(r, "onyx"))) uassert.NoError(t, r.Propose(alice, 2, "onyx", onyx())) uassert.Equal(t, 0, r.Clearable("onyx")) } // URL characters, escapes, schemes, selectors, Parse trimming, pending // restatement and stored tails. func TestURLAndTextRules(t *testing.T) { // Every character a URL may not hold, one at a time. for _, c := range []string{"<", ">", "`", "(", ")", "[", "]", "{", "}", "|", "\\", "^", "#", "\"", "'"} { uassert.Error(t, ValidateEndpoint(RPC, "https://h.example.com/a"+c+"b"), c) } // A needless escape of a digit or a lowercase letter; the hex case of the // first escape digit. uassert.Error(t, ValidateEndpoint(RPC, "https://h.example.com/a%30")) uassert.Error(t, ValidateEndpoint(RPC, "https://h.example.com/a%61")) uassert.Equal(t, Canonical(RPC, "https://h.example.com/%af"), Canonical(RPC, "https://h.example.com/%AF")) // A hex last label reads as an IPv4 number. uassert.Error(t, ValidateEndpoint(RPC, "https://a.0x1")) uassert.True(t, IsPrivateHost("a.0x1")) uassert.True(t, IsPrivateHost("PRINTER.LOCAL")) // Schemes per field. in := onyx() in.GnowebURL = "tcp://onyx.example.com:26657" uassert.Error(t, ValidateInfo(in)) uassert.Error(t, ValidateEndpoint(Indexer, "tcp://indexer.example.com:8546")) // Variation selectors only after a base they modify. for _, s := range []string{"a\ufe00", "᠀\u180b"} { in := onyx() in.Title = s uassert.ErrorContains(t, ValidateInfo(in), "invisible", s) } ok := onyx() ok.Title = "ᠨ\u180f" uassert.NoError(t, ValidateInfo(ok)) uassert.False(t, HasVisible("\u200b\u200d")) // The Parse functions trim. st, err := ParseStatus(" approved ") uassert.NoError(t, err) uassert.Equal(t, string(Approved), string(st)) k, err := ParseEndpointKind(" rpc ") uassert.NoError(t, err) uassert.Equal(t, string(RPC), string(k)) v, err := ParseVerification(" verified ") uassert.NoError(t, err) uassert.Equal(t, string(Verified), string(v)) // Nothing goes back to pending, not even as a restatement. r := NewRegistry() uassert.NoError(t, r.Propose(alice, 1, "onyx", onyx())) uassert.Error(t, r.ReviewZone("onyx", Pending, rev(r, "onyx"), curator, 2, "x")) z, _ := r.Zone("onyx") uassert.Equal(t, "", z.ReviewedBy.String()) // An empty query and a bare "/" are not stored, so the dialable spelling // is the one listed. id, err := r.Register(alice, 3, "onyx", RPC, "https://rpc2.zz.example.com/?", "") uassert.NoError(t, err) e, _ := r.Endpoint(id) uassert.Equal(t, "https://rpc2.zz.example.com", e.Address) _, err = r.Register(alice, 3, "onyx", RPC, "https://rpc2.zz.example.com", "") uassert.ErrorContains(t, err, "already lists") // More private suffixes, more look-alikes. for _, h := range []string{"rpc.node.incus", "web.dns.podman", "foo.i2p", "gnoland.default.svc"} { uassert.True(t, IsPrivateHost(h), h) } for _, r := range []rune{0x1FBBD, 0x1F6AD, 0x1F6AF, 0x1F6B1, 0x1F6B3, 0x1F6B7, 0x1F4F5, 0x1F51E, 0x1F10D, 0x1F10F, 0x1F16E} { uassert.Error(t, ValidateLabel(string(r)+" official"), string(r)) } } // What is stored canonicalizes as what was typed, so a check made on the // typed address (the realm's own-URL reservation) holds for the stored one. func TestTheStoredFormKeepsItsCanonicalForm(t *testing.T) { for _, u := range []string{"https://h.example.com??", "https://h.example.com/??", "https://h.example.com?/", "https://h.example.com?path=/", "https://h.example.com/p?x=?", "https://h.example.com/p??", "https://h.example.com/?", "https://h.example.com/", "https://h.example.com?", "https://h.example.com/?q=1", "https://h.example.com/p/"} { uassert.Equal(t, Canonical(RPC, u), Canonical(RPC, trimEmptyTail(lowerAuthority(u))), u) } uassert.Equal(t, "https://h.example.com?path=/", trimEmptyTail("https://h.example.com?path=/")) uassert.Equal(t, "https://h.example.com??", trimEmptyTail("https://h.example.com??")) } // Spaces of every kind (Unicode Zs) and tabs are trimmed; a line separator at // an edge reaches the validator and is refused, never silently cut. A zone's // gnoweb and genesis URLs have an empty tail repaired, its main RPC is refused // with one, and an endpoint is validated as it will be stored. func TestTrimsSpacesNotLineBreaks(t *testing.T) { r := NewRegistry() in := onyx() in.Title = "Onyx\u2028" uassert.ErrorContains(t, r.Propose(alice, 1, "onyx", in), "control character") in = onyx() in.Description = "\u0085" + in.Description uassert.ErrorContains(t, r.Propose(alice, 1, "onyx", in), "control character") uassert.NoError(t, r.Propose(alice, 1, "onyx", onyx())) _, err := r.Register(alice, 2, "onyx", RPC, "https://a.example.com", "operator\u2028") uassert.ErrorContains(t, err, "control character") uassert.Equal(t, "x", TrimSpaces(" \tx\t ")) for _, u := range []string{"https://g.example.com/", "https://g.example.com?", "https://g.example.com/?"} { r := NewRegistry() in := onyx() in.GnowebURL, in.GenesisURL = u, u uassert.NoError(t, r.Propose(alice, 1, "xz", in), u) z, _ := r.Zone("xz") uassert.Equal(t, "https://g.example.com", z.GnowebURL, u) uassert.Equal(t, "https://g.example.com", z.GenesisURL, u) } for _, u := range []string{"https://rpc.example.com/", "https://rpc.example.com?"} { bad := onyx() bad.RPCURL = u uassert.Error(t, ValidateInfo(bad), u) uassert.Error(t, NewRegistry().Propose(alice, 1, "xz", bad), "the registry refuses it too: "+u) } uassert.Equal(t, "Onyx", TrimSpaces("\u00a0Onyx\u3000")) uassert.Equal(t, "Onyx\n", TrimSpaces("Onyx\n")) // ValidateEndpoint judges the string it is given; Register stores the // trimmed form and validates that. uassert.Error(t, ValidateEndpoint(RPC, "tcp://rpc.example.com:26657/"), "as given, it has a path") id, err := r.Register(alice, 2, "onyx", RPC, "tcp://rpc.example.com:26657/", "") uassert.NoError(t, err) e, _ := r.Endpoint(id) uassert.Equal(t, "tcp://rpc.example.com:26657", e.Address) long := "https://e.example.com/" + strings.Repeat("a", MaxURLLen-len("https://e.example.com/")) _, err = r.Register(alice, 2, "onyx", Explorer, long+"?", "") uassert.NoError(t, err, "257 bytes as typed, 256 as stored") _, err = r.Register(alice, 2, "onyx", Explorer, long+"b", "") uassert.Error(t, err, "257 as stored") } // Chain id charset, text and URL bounds, controls, escapes and trims. func TestMoreBoundaries(t *testing.T) { in := onyx() in.ChainID = "onyx:1" uassert.Error(t, ValidateInfo(in)) uassert.Error(t, ValidateReason(strings.Repeat("x", MaxReasonLen+1))) uassert.NoError(t, ValidateReason(strings.Repeat("x", MaxReasonLen))) uassert.Error(t, ValidateLabel(strings.Repeat("x", MaxLabelLen+1))) uassert.NoError(t, ValidateLabel(strings.Repeat("x", MaxLabelLen))) for _, c := range []string{"\x1f", "\x7f", "\u009f"} { uassert.ErrorContains(t, ValidateLabel("a"+c+"b"), "control character") } ok := onyx() ok.Title = "ᠠ\u180b" uassert.NoError(t, ValidateInfo(ok)) host := "https://" + strings.Repeat("a", 20) + ".example.com/" long := host + strings.Repeat("p", MaxURLLen-len(host)) uassert.NoError(t, ValidateEndpoint(RPC, long)) uassert.Error(t, ValidateEndpoint(RPC, long+"p")) uassert.Error(t, ValidateEndpoint(RPC, "https://h.example.com/a\x7fb")) uassert.NoError(t, ValidateEndpoint(Explorer, "https://h.example.com/a&;b")) uassert.Error(t, ValidateEndpoint(RPC, "https://h.example.com/a%2z")) uassert.NoError(t, ValidateEndpoint(Explorer, "https://h.example.com/p?a=/../")) uassert.Error(t, ValidateEndpoint(RPC, "https://a.0X1")) uassert.Error(t, ValidateEndpoint(RPC, "https://256.1.1.1")) r := NewRegistry() in = onyx() in.Description = " \t" + in.Description + "\t " uassert.NoError(t, r.Propose(alice, 1, "onyx", in)) z, _ := r.Zone("onyx") uassert.Equal(t, onyx().Description, z.Description) edited := onyx() edited.Title = "Onyx, edited" uassert.ErrorContains(t, r.Edit("onyx", rev(r, "onyx"), edited, alice, 2, "x"), "takes no reason") } // Boundaries the suites reached only in part. func TestFurtherBoundaries(t *testing.T) { uassert.ErrorContains(t, ValidateLabel(" "), "nothing visible") uassert.Error(t, ValidateLabel("a\U000E01EF")) uassert.Error(t, ValidateEndpoint(RPC, "https://h.example.com/a%2!")) uassert.Error(t, ValidateEndpoint(RPC, "https://h.example.com/%2d")) uassert.Error(t, ValidateEndpoint(RPC, "https://1.2.3.4.5")) uassert.Error(t, ValidateEndpoint(Peer, nodeID+"@"+strings.Repeat("a", 250)+".example.com:26656")) uassert.Equal(t, 16, ReservedForReviewers) r := NewRegistry() in := onyx() in.ChainID = " onyx-1 " uassert.NoError(t, r.Propose(alice, 1, "onyx", in)) z, _ := r.Zone("onyx") uassert.Equal(t, "onyx-1", z.ChainID) uassert.NoError(t, r.ReviewZone("onyx", Rejected, rev(r, "onyx"), curator, 2, "first")) uassert.NoError(t, r.ReviewZone("onyx", Rejected, rev(r, "onyx"), carol, 9, "restated")) z, _ = r.Zone("onyx") uassert.Equal(t, int64(9), z.ReviewedAt, "a restatement records when") } // Round 13: rules that had no test of their own. func TestRoundThirteenBoundaries(t *testing.T) { // HasVisible is exported, so its filler rule is its own, not checkText's. uassert.False(t, HasVisible("\u3164"), "a Hangul filler is not visible") uassert.True(t, HasVisible("a\u3164")) // A 0x last label is held to the address rule even when it is not hex. uassert.Error(t, ValidateEndpoint(RPC, "https://a.0xyz")) // A peer is at most MaxURLLen bytes, exactly. peer := func(n int) string { host := strings.Repeat("a", 63) + "." + strings.Repeat("b", 63) + "." + strings.Repeat("c", 63) + "." pre, post := nodeID+"@", ".com:26656" return pre + host + strings.Repeat("d", n-len(pre)-len(host)-len(post)) + post } uassert.Equal(t, MaxURLLen, len(peer(MaxURLLen))) uassert.NoError(t, ValidateEndpoint(Peer, peer(MaxURLLen))) uassert.Error(t, ValidateEndpoint(Peer, peer(MaxURLLen+1))) // A filtered list is capped at its length, so an importer's append copies // instead of writing into a slice it may not write. r := NewRegistry() uassert.NoError(t, r.Propose(alice, 1, "onyx", onyx())) for i := 0; i < 3; i++ { _, err := r.Register(alice, 1, "onyx", RPC, "https://r"+strconv.Itoa(i)+".example.com", "") uassert.NoError(t, err) } id, err := r.Register(alice, 1, "onyx", RPC, "https://v.example.com", "") uassert.NoError(t, err) e, _ := r.Endpoint(id) z, _ := r.Zone("onyx") uassert.NoError(t, r.ReviewEndpoint(id, Verified, z.Revision, e.Revision, alice, 1, "")) for _, es := range [][]Endpoint{ r.Endpoints(EndpointFilter{Zone: "onyx", Status: Verified}), r.Endpoints(EndpointFilter{Zone: "onyx", Kind: RPC, Status: Verified}), r.Endpoints(EndpointFilter{Status: Verified}), } { uassert.Equal(t, 1, len(es)) uassert.Equal(t, len(es), cap(es)) } uassert.NoError(t, r.Propose(alice, 1, "dev", Info{ChainID: "dev", Title: "D", Kind: Devnet, RPCURL: "https://rpc.dev.example.com"})) for _, zs := range [][]Zone{r.Zones(ZoneFilter{Kind: Devnet}), r.Zones(ZoneFilter{Status: Pending, Kind: Devnet})} { uassert.Equal(t, 1, len(zs)) uassert.Equal(t, len(zs), cap(zs)) } }
  10. #10zones.gno
  11. #11// Package zones is the content model of a curated registry of gno.land // networks: what a zone is, what an endpoint on one is, which strings each // field accepts, and the curation states both move through. // // A zone is one network a person can point a node or a wallet at: mainnet, a // testnet, a staging chain, somebody's gnodev. An endpoint is one way in: an // RPC, a gnoweb, a seed or persistent peer, an indexer, a faucet, an explorer. // Anybody may propose a zone, and register endpoints as the holding realm // allows; a curator decides which // zones are official and which endpoints are verified. Every decision is // recorded with who made it and when; a rejection, a retirement, a flag and an // edit to an approved zone also require a reason, which the public reads. // // The package decides nothing about WHO may act. Every write that records a // decision takes the acting address and the height as arguments (the two // removals take neither), and the realm holding the [Registry] // decides whether that address is a curator, the proposer, or nobody. That is // the split that lets the same model move under a different authority later (a // DAO, a system realm) without a line changing here. // // Live registry: r/moul/zones. package zones import ( "errors" "strconv" "strings" "unicode" "unicode/utf8" ) // Status is where a zone stands in curation. type Status string const ( // Pending is a proposal nobody has reviewed yet. Every zone starts here. Pending Status = "pending" // Approved is an official zone: the one state a reader should trust. Approved Status = "approved" // Rejected is a proposal a curator turned down, with the reason kept. Rejected Status = "rejected" // Retired is a zone that was official and no longer runs. It stays // listed, because a node operator holding its chain id deserves to find // out why nothing answers, until MaxRetired newer retirements push it out. Retired Status = "retired" ) // Kind says what sort of network a zone is. type Kind string const ( Mainnet Kind = "mainnet" Testnet Kind = "testnet" Devnet Kind = "devnet" Local Kind = "local" ) // EndpointKind says what an endpoint is for, and therefore which address // shape it accepts. type EndpointKind string const ( RPC EndpointKind = "rpc" // a tm2 JSON-RPC: http(s) and tcp for gnokey, ws(s) for a subscriber Gnoweb EndpointKind = "gnoweb" // a gnoweb frontend Seed EndpointKind = "seed" // a p2p seed, for p2p.seeds Peer EndpointKind = "peer" // a p2p node, for p2p.persistent_peers Indexer EndpointKind = "indexer" // a tx-indexer GraphQL endpoint Faucet EndpointKind = "faucet" // a faucet page or API Explorer EndpointKind = "explorer" // a block explorer ) // Verification is a curator's verdict on an endpoint. type Verification string const ( // Unverified is every endpoint until a curator looks at it. Listed, and // labelled as such, never hidden: an unverified RPC is still an RPC. Unverified Verification = "unverified" // Verified means a curator checked it answers for this zone. Verified Verification = "verified" // Flagged means a curator says do not use it, and says why. Flagged Verification = "flagged" ) // Statuses, Kinds, EndpointKinds and Verifications list every value of each // enum in display order, for a Render that wants one section per value. func Statuses() []Status { return []Status{Approved, Pending, Rejected, Retired} } func Kinds() []Kind { return []Kind{Mainnet, Testnet, Devnet, Local} } func EndpointKinds() []EndpointKind { return []EndpointKind{RPC, Gnoweb, Seed, Peer, Indexer, Faucet, Explorer} } func Verifications() []Verification { return []Verification{Verified, Unverified, Flagged} } // Bounds on every caller-supplied string. A bound rather than none: every // stored byte locks a storage deposit, and an unbounded field is a bill a // stranger chooses the size of. const ( MinSlugLen = 2 MaxSlugLen = 32 MaxChainIDLen = 50 // tm2's own limit on a chain id MaxTitleLen = 64 MaxDescriptionLen = 512 MaxURLLen = 256 MaxLabelLen = 64 MaxReasonLen = 280 ) // Info is everything a proposer describes about a zone. It is the part that // can be edited; the slug, the status and the history cannot. type Info struct { ChainID string // what a node's genesis and a signer's -chainid say Title string Description string Kind Kind GnowebURL string // optional: a local chain may not run one RPCURL string // required: the one endpoint every tool needs GenesisURL string // optional: where to download genesis.json } // Zone is a network, as the registry holds it. // // Flat on purpose: every field is a scalar. A nested struct inside a persisted // object is stored as an object of its own, and `gnokey query vm/qeval` prints // it as an opaque ref(...) instead of its fields, so a reader asking a node for // a zone would get the slug and nothing they came for. [Zone.Info] gives the // editable part back as one value. type Zone struct { Slug string // the key: [a-z0-9-], stable, and what a URL carries ChainID string Title string Description string Kind Kind GnowebURL string RPCURL string GenesisURL string Status Status Proposer address ProposedAt int64 // Revision changes on every edit of the zone's Info, on every status // change and on every restated decision, and is never reused, // not even by a zone removed and proposed again under the same slug. A // curator acting on a zone names the revision they read, so an edit that // lands between their reading and their decision makes the decision fail // instead of attaching their name to text they never saw. Revision int64 EditedBy address // who last edited the Info; empty if nobody has EditedAt int64 Entered int64 // when it entered its current status, in registry order: eviction goes oldest first // The latest curator decision, empty until there is one. A curator's edit // to an approved zone is a decision too, and replaces these. ReviewedBy address ReviewedAt int64 Reason string } // Info returns the part of the zone a proposer described. func (z Zone) Info() Info { return Info{ ChainID: z.ChainID, Title: z.Title, Description: z.Description, Kind: z.Kind, GnowebURL: z.GnowebURL, RPCURL: z.RPCURL, GenesisURL: z.GenesisURL, } } // Reviewed reports whether a curator has decided anything about the zone, // including an edit made after its first review. func (z Zone) Reviewed() bool { return z.ReviewedBy != "" } func (z *Zone) setInfo(in Info) { z.ChainID = in.ChainID z.Title = in.Title z.Description = in.Description z.Kind = in.Kind z.GnowebURL = in.GnowebURL z.RPCURL = in.RPCURL z.GenesisURL = in.GenesisURL } // Endpoint is one way into a zone, as the registry holds it. Flat for the same // reason as [Zone]. type Endpoint struct { ID int64 Zone string // the zone's slug Kind EndpointKind Address string // a URL, or id@host:port for a seed or a peer Label string // who runs it, or what it is, in the registrant's words Registrant address RegisteredAt int64 Status Verification ReviewedBy address ReviewedAt int64 Reason string // Revision is bumped, from the registry-wide counter zones use, when the // endpoint is registered, on every verdict and on every reset. A verdict // and a removal name it, so either fails on an endpoint that changed // after it was read. Revision int64 // Exempt marks an endpoint registered through RegisterExempt, by a // reviewer the holder trusts: never clearable as a never-reviewed one, // whoever is a reviewer later. Exempt bool } // Clearable reports whether nobody has ruled on the endpoint and it was not a // reviewer's own registration: no verdict (every verdict names its // reviewer), no reset (every reset leaves a reason), not Exempt. It is what // a bulk clear of a flood may remove. func (e Endpoint) Clearable() bool { return !e.Exempt && e.ReviewedBy == "" && e.Reason == "" } // ParseStatus reads a status from a caller's string. "" is the zero Status, // which a filter reads as "any". func ParseStatus(s string) (Status, error) { switch st := Status(TrimSpaces(s)); st { case "", Pending, Approved, Rejected, Retired: return st, nil } return "", errors.New("zones: unknown status " + strconv.Quote(s) + ", want approved, pending, rejected or retired") } // ParseKind reads a zone kind. "" is the zero Kind, "any" to a filter. func ParseKind(s string) (Kind, error) { switch k := Kind(TrimSpaces(s)); k { case "", Mainnet, Testnet, Devnet, Local: return k, nil } return "", errors.New("zones: unknown kind " + strconv.Quote(s) + ", want mainnet, testnet, devnet or local") } // ParseEndpointKind reads an endpoint kind. "" is "any" to a filter. func ParseEndpointKind(s string) (EndpointKind, error) { switch k := EndpointKind(TrimSpaces(s)); k { case "", RPC, Gnoweb, Seed, Peer, Indexer, Faucet, Explorer: return k, nil } return "", errors.New("zones: unknown endpoint kind " + strconv.Quote(s) + ", want rpc, gnoweb, seed, peer, indexer, faucet or explorer") } // ParseVerification reads an endpoint verdict. "" is "any" to a filter. func ParseVerification(s string) (Verification, error) { switch v := Verification(TrimSpaces(s)); v { case "", Unverified, Verified, Flagged: return v, nil } return "", errors.New("zones: unknown verification " + strconv.Quote(s) + ", want verified, unverified or flagged") } // ValidateSlug accepts 2 to 32 characters of [a-z0-9-], starting and ending // with a letter or a digit. // // Checked at write time rather than escaped at render time, deliberately: the // slug is also an index key and a URL path segment, so one carrying a slash or // a pipe would break the link and the table as well as the page. func ValidateSlug(s string) error { if len(s) < MinSlugLen || len(s) > MaxSlugLen { return errors.New("zones: a slug is " + strconv.Itoa(MinSlugLen) + " to " + strconv.Itoa(MaxSlugLen) + " characters, got " + strconv.Quote(s)) } for i, r := range s { alnum := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') if alnum || (r == '-' && i > 0 && i < len(s)-1) { continue } return errors.New("zones: a slug is [a-z0-9-] and starts and ends alphanumeric, got " + strconv.Quote(s)) } return nil } // ValidateChainID accepts what tm2 accepts for a chain id: 1 to 50 // characters, here narrowed to [A-Za-z0-9._-] so it is safe raw in a table. func ValidateChainID(s string) error { if s == "" || len(s) > MaxChainIDLen { return errors.New("zones: a chain id is 1 to " + strconv.Itoa(MaxChainIDLen) + " characters") } for _, r := range s { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '_', r == '-': default: return errors.New("zones: a chain id is [A-Za-z0-9._-], got " + strconv.Quote(s)) } } return nil } // ValidateInfo checks every field of a zone's description, and returns the // first problem it finds. func ValidateInfo(in Info) error { if err := ValidateChainID(in.ChainID); err != nil { return err } if err := checkText("title", in.Title, 1, MaxTitleLen); err != nil { return err } if err := checkText("description", in.Description, 0, MaxDescriptionLen); err != nil { return err } // Exact values only: a Kind(" testnet ") stored as written would match no // filter (the Registry trims before it validates; a direct caller must). switch in.Kind { case Mainnet, Testnet, Devnet, Local: case "": return errors.New("zones: a zone needs a kind: mainnet, testnet, devnet or local") default: return errors.New("zones: unknown kind " + strconv.Quote(string(in.Kind)) + ", want mainnet, testnet, devnet or local") } if err := checkURL("rpc url", in.RPCURL, primarySchemes); err != nil { return err } // gnokey's -remote is <scheme>://<host>[:<port>] and reads anything after // :// as the socket address: a path, a query, a fragment, even a lone // trailing slash, prints a command that cannot dial (https://host/ dials // "host/:443"). An rpc ENDPOINT may still carry a path. if _, rest, _ := strings.Cut(in.RPCURL, "://"); strings.IndexAny(rest, "/?#") >= 0 { return errors.New("zones: the rpc url is <scheme>://<host>[:<port>], what gnokey -remote takes, with no path, not even a trailing slash: " + strconv.Quote(in.RPCURL)) } for _, u := range [][2]string{{"gnoweb url", in.GnowebURL}, {"genesis url", in.GenesisURL}} { if u[1] == "" { continue } if err := checkURL(u[0], u[1], webSchemes); err != nil { return err } } // A loopback or private address names a different machine for every // reader. Fine for a local zone, which is exactly that; on any other kind // it points a config generator at whatever answers inside the reader's own // network. if in.Kind != Local { for _, u := range []string{in.RPCURL, in.GnowebURL, in.GenesisURL} { if u != "" && IsPrivateHost(HostOf(RPC, u)) { return errors.New("zones: " + strconv.Quote(u) + " names a private or special-use host; only a local zone lists those") } } } return nil } // HostOf returns the host of an endpoint address: the part between :// and // the port or path of a URL, or between @ and the port of a peer. It assumes // an address that already passed validation. func HostOf(kind EndpointKind, addr string) string { if kind == Seed || kind == Peer { _, hostport, _ := strings.Cut(addr, "@") if i := strings.LastIndexByte(hostport, ':'); i >= 0 { return hostport[:i] } return hostport } _, rest, _ := strings.Cut(addr, "://") if i := strings.IndexAny(rest, "/?"); i >= 0 { rest = rest[:i] } if i := strings.LastIndexByte(rest, ':'); i >= 0 { rest = rest[:i] } return rest } // IsPrivateHost reports whether host names a machine that is different for // every reader, or no machine at all: // // - a name with no dot (resolved through the reader's own search domain), or // one under a suffix reserved for local or private use, or that public DNS // does not delegate: .localhost, .local (mDNS), .internal, .localdomain, // .test, .example, .invalid, .lan, .home, .corp, .mail, .intranet, // .private, .alt, .onion and .i2p, the service-discovery and container names // .consul, .lxd, .incus, .docker, .podman, .localnet and .svc, and every // .arpa name, which is infrastructure // and never a public service (.home.arpa, ipv4only.arpa, // default.service.arpa). Hosts files' localhost4, localhost6, // ip6-localhost and ip6-loopback have no dot and fall under the first rule; // - an IPv4 address in a loopback, private, link-local, carrier-grade NAT, // "this network", IETF-protocol, documentation, benchmarking, 6to4 relay // anycast, multicast or reserved range; // - anything else shaped like a number but not a valid dotted quad, so an // outside caller is not told 127.1 is public (validation refuses it anyway). // // It fails closed: anything but a bare host ([A-Za-z0-9.-] only) is private // to it. It looks at the string only; a public name that resolves to a private // address is beyond what a registry can know. func IsPrivateHost(host string) bool { // Fail closed: anything but a bare host (a port, a path, a stray // character) is not one this can vouch for. for i := 0; i < len(host); i++ { if c := host[i]; !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '.' || c == '-') { return true } } h := strings.ToLower(strings.TrimSuffix(host, ".")) if !strings.Contains(h, ".") { return true } for _, suffix := range []string{".localhost", ".local", ".internal", ".arpa", ".localdomain", ".test", ".example", ".invalid", ".lan", ".home", ".corp", ".mail", ".intranet", ".private", ".onion", ".alt", ".consul", ".lxd", ".incus", ".docker", ".podman", ".localnet", ".svc", ".i2p"} { if strings.HasSuffix(h, suffix) { return true } } labels := strings.Split(h, ".") last := labels[len(labels)-1] numeric := strings.Trim(last, "0123456789") == "" || strings.HasPrefix(last, "0x") if !numeric { return false } if checkIPv4(h) != nil { return true } a, _ := strconv.Atoi(labels[0]) b, _ := strconv.Atoi(labels[1]) c, _ := strconv.Atoi(labels[2]) switch { case a == 0, a == 10, a == 127, a >= 224: return true case a == 169 && b == 254, a == 192 && b == 168: return true case a == 172 && b >= 16 && b <= 31, a == 100 && b >= 64 && b <= 127: return true case a == 192 && b == 0 && c == 0, a == 198 && (b == 18 || b == 19), a == 192 && b == 88 && c == 99: return true case a == 192 && b == 0 && c == 2, a == 198 && b == 51 && c == 100, a == 203 && b == 0 && c == 113: return true // documentation ranges } return false } // ValidateEndpoint checks an endpoint's address against the shape its kind // needs: a URL for everything but a seed or a peer, which are id@host:port. func ValidateEndpoint(kind EndpointKind, addr string) error { switch kind { case RPC: if err := checkURL("rpc address", addr, rpcSchemes); err != nil { return err } // gnokey dials a tcp:// remote as host:port, path and all, so one with // a path is undialable, and Canonical would read it as an http URL // that is a different resource. if scheme, rest, _ := strings.Cut(addr, "://"); strings.EqualFold(scheme, "tcp") && strings.IndexAny(rest, "/?") >= 0 { return errors.New("zones: a tcp:// rpc address is host and port only, no path or query") } return nil case Indexer: // A tx-indexer serves GraphQL subscriptions over a websocket too. return checkURL("indexer address", addr, rpcSchemes[:4]) case Gnoweb, Faucet, Explorer: return checkURL(string(kind)+" address", addr, webSchemes) case Seed, Peer: return checkPeer(addr) case "": return errors.New("zones: an endpoint needs a kind") } // Exact values only. The Parse functions trim a caller's string, so an // EndpointKind(" rpc ") would parse, be stored as written, and then match // no filter: refused here rather than normalised behind the caller's back. return errors.New("zones: unknown endpoint kind " + strconv.Quote(string(kind)) + ", want rpc, gnoweb, seed, peer, indexer, faucet or explorer") } // ValidateLabel accepts an optional single line of up to 64 characters. func ValidateLabel(s string) error { return checkText("label", s, 0, MaxLabelLen) } // ValidateReason accepts an optional single line of up to 280 characters. // Whether a reason is REQUIRED depends on the decision; see [Registry]. func ValidateReason(s string) error { return checkText("reason", s, 0, MaxReasonLen) } var ( // rpcSchemes is what an rpc ENDPOINT may be: a websocket subscriber is a // real consumer of one. rpcSchemes = []string{"https", "http", "wss", "ws", "tcp"} // primarySchemes is what a zone's main RPC may be, narrower on purpose: it // is what every tool is pointed at first, gnokey's -remote included, and // gnokey's query client dials http, https and tcp only, so a wss:// main // RPC would print a command that cannot run. primarySchemes = []string{"https", "http", "tcp"} webSchemes = []string{"https", "http"} ) // ValidAddress reports whether a is a valid g1 address in the one spelling the // chain uses for it: lowercase. bech32 also decodes an all-uppercase string, so // IsValid accepts G1ABC…, but every comparison here (curators, proposers, // registrants, node ids) is on the string, and an uppercase spelling of a real // address would be a second identity for it. func ValidAddress(a address) bool { return a.IsValid() && string(a) == strings.ToLower(string(a)) } // HasVisible reports whether s has at least one character a reader can see: // not a space, not a combining mark, not an invisible format character, not a // blank filler. A // required title or reason must, or it passes the "needs a reason" check and // renders blank. func HasVisible(s string) bool { for _, r := range s { if !unicode.IsSpace(r) && !unicode.IsMark(r) && !unicode.Is(unicode.Cf, r) && !isBlankFiller(r) { return true } } return false } // isBlankFiller is the handful of characters that are letters or symbols by // category and still draw nothing: the Hangul fillers, the blank Braille cell, // the musical null notehead, the Egyptian hieroglyph blanks, and the Khmer // inherent vowels and the Khitan filler (marks by category, drawn as nothing). Unicode does not class them as format // characters, so they need naming. func isBlankFiller(r rune) bool { switch r { case 0x115F, 0x1160, 0x3164, 0xFFA0, 0x2800, 0x1D159, 0x17B4, 0x17B5, 0x13441, 0x13442, 0x16FE4: return true } return false } // isSelector is the variation selectors, all three blocks (U+180E, inside the // Mongolian range, is a format character and refused as one): invisible on their // own, and an invisible difference between two spellings of a name. func isSelector(r rune) bool { return (r >= 0xFE00 && r <= 0xFE0F) || (r >= 0xE0100 && r <= 0xE01EF) || (r >= 0x180B && r <= 0x180F) } // selects reports whether a variation selector modifies the character before // it, the one place it is text rather than an invisible difference: the // emoji and text presentation selectors after a symbol (a phone writes ❤️ as // U+2764 U+FE0F), the Mongolian free variation selectors after a Mongolian // letter, and an ideographic variation sequence after a Han ideograph. A // selector cannot make a badge: every symbol that looks like one is refused on // its own. func selects(base, sel rune) bool { switch { case sel == 0xFE0E || sel == 0xFE0F: // The symbols, and the five emoji whose base is punctuation or a // letter by category: ‼ ⁉ ℹ 〰 〽. return unicode.In(base, unicode.So, unicode.Sm) || base == 0x203C || base == 0x2049 || base == 0x2139 || base == 0x3030 || base == 0x303D case sel >= 0x180B && sel <= 0x180F && sel != 0x180E: return base >= 0x1820 && base <= 0x18AA case sel >= 0xE0100 && sel <= 0xE01EF: return unicode.Is(unicode.Han, base) } return false } // isBadge is the status glyphs Render draws, and their look-alikes, refused in // free text so a title cannot claim "✔ official" beside a pending badge. func isBadge(r rune) bool { switch r { case 0x2705, 0x26A0, 0x23F3, 0x274C, 0x23F9, // ✅ ⚠ ⏳ ❌ ⏹, what Render draws 0x2713, 0x2714, 0x2611, 0x1F5F8, 0x1F5F9, 0x1F197, // check marks, 🆗 0x2716, 0x2717, 0x2718, 0x2715, 0x274E, 0x2612, 0x1F6AB, 0x26D4, 0x1F6D1, // crosses, no-entry 0x1F5F4, 0x1F5F5, 0x1F5F6, 0x1F5F7, // ballot x and ballot box with x 0x231B, 0x23F8, 0x23FA, // ⌛ ⏸ ⏺ 0x1FBB1, 0x237B, 0x10102, // more check marks 0x1F5D9, 0x2A2F, 0x2573, 0x2BBD, 0x2BBE, 0x2BBF, // more crosses and ballot boxes 0x29D6, 0x29D7, // hourglasses 0x2613, 0x2A09, // saltire, n-ary times (× itself is everyday text: 1920×1080) 0x22A0, 0x2327, 0x1F147, 0x1F187, 0x2297, 0x2A02, 0x1F167, // boxed and circled crosses 0x1F6C7, 0x2298, 0x29B8, // prohibition signs 0x24CD, 0x24E7, 0x24B3, 0x1F127, 0x29BB, 0x2A34, 0x2A35, 0x2A37, 0x292B, 0x292C, // circled, parenthesized and crossing x 0x1F532, 0x1F533, // square buttons, beside ⏹ 0x26DD, 0x1F5BE, 0x2B59, 0x2A36, 0x26D2, 0x1FBC0, 0x1D145, 0x26CC, 0x2A3B, // more boxed, circled and heavy crosses 0x1FBBD, 0x1F6AD, 0x1F6AF, 0x1F6B1, 0x1F6B3, 0x1F6B7, 0x1F4F5, 0x1F51E, 0x1F10D, 0x1F10F, 0x1F16E: // more no-entry signs return true } return r >= 0x1F7A8 && r <= 0x1F7AE // the geometric crosses beside ✖ } // checkText bounds a free-text field by runes (so at most four times as many // bytes) and keeps it to one line of text that shows what it stores: // // - no control character: C0, DEL, C1, U+2028, U+2029. Several are line // breaks that ui.Inline folds to a space, the rest draw nothing; either // way the text shown would not be the text stored. // - no invisible or undrawable character: every format character (Unicode // Cf, which covers the bidi controls and isolates, the zero-width // characters, U+061C, word joiners, tags), every character that is not // graphic (private use, unassigned, noncharacters), the blank fillers, a // variation selector except right after a base it modifies (selects), the // combining grapheme joiner. A title made of them // would pass the length check and render blank or as a box. // - no enclosing mark, which draws a badge's frame around any character, // and no run of more than four nonspacing marks, which stack over // neighbouring lines (a spacing mark takes its own width and ends a run, // so Devanagari, Gurmukhi, Burmese and Tibetan words pass), and none of // the status glyphs a Render draws. // - valid UTF-8, because the page would show U+FFFD for a byte stored raw. // // Refused rather than stripped, so what is stored is what is shown. func checkText(field, s string, min, max int) error { // No rune is more than four bytes: anything longer cannot be within max // characters, and is refused before a per-rune scan the writer pays for. if len(s) > 4*max { return errors.New("zones: the " + field + " is longer than " + strconv.Itoa(max) + " characters") } n, marks, prev := 0, 0, rune(0) for i, r := range s { if r == utf8.RuneError { if _, size := utf8.DecodeRuneInString(s[i:]); size <= 1 { return errors.New("zones: the " + field + " is not valid UTF-8") } } if r < 0x20 || (r >= 0x7f && r <= 0x9f) || r == 0x2028 || r == 0x2029 { return errors.New("zones: the " + field + " contains a control character") } if unicode.Is(unicode.Cf, r) || isBlankFiller(r) || r == 0x034F || (isSelector(r) && !selects(prev, r)) { return errors.New("zones: the " + field + " contains an invisible or bidi character") } if !unicode.IsGraphic(r) && !unicode.IsSpace(r) { // The chain's unicode tables are Unicode 15.0, so a character // assigned since then is refused here even where gno test (which // uses the host's newer tables) accepts it. return errors.New("zones: the " + field + " contains " + strconv.QuoteToASCII(string(r)) + ", which this chain does not draw: private use, a noncharacter, or unassigned in its Unicode 15.0 tables") } if isBadge(r) { return errors.New("zones: the " + field + " contains a status glyph a Render draws, or a look-alike of one: " + strconv.Quote(string(r))) } if unicode.Is(unicode.Me, r) { // An enclosing mark draws a frame around what precedes it: !\u20E4 // is a warning triangle, v\u20DE a checked box. No living script // needs one, and keycaps need U+FE0F, refused above. return errors.New("zones: the " + field + " contains an enclosing mark, which draws a status glyph's frame") } if unicode.Is(unicode.Mn, r) { marks++ if marks > 4 { return errors.New("zones: the " + field + " stacks more than four combining marks") } } else { marks = 0 } prev = r n++ if n > max { break // refused below; no need to classify the rest } } if n > max { return errors.New("zones: the " + field + " is longer than " + strconv.Itoa(max) + " characters") } if n < min { return errors.New("zones: the " + field + " is " + strconv.Itoa(min) + " to " + strconv.Itoa(max) + " characters, got " + strconv.Itoa(n)) } if s != "" && !HasVisible(s) { return errors.New("zones: the " + field + " has nothing visible in it") } return nil } // checkURL accepts scheme://host[:port][/path][?query], in visible ASCII (no // space, no control), with no character that could close a markdown link or // open a new construct around it. Narrower than RFC 3986 on purpose: these // URLs are rendered as links and copied into config files, and neither wants // a quote or a bracket. Also refused: // // - a fragment (#): it is never sent to the server, so every #1, #2 would be // another listing of the same endpoint. // - a % not followed by two hex digits, and an &name; shape: the link // sanitizer re-encodes both, so the href would differ from the text shown. func checkURL(field, s string, schemes []string) error { if s == "" { return errors.New("zones: the " + field + " is empty") } if len(s) > MaxURLLen { return errors.New("zones: the " + field + " is longer than " + strconv.Itoa(MaxURLLen) + " bytes") } n := len(s) for i := 0; i < n; i++ { c := s[i] if c <= ' ' || c >= 0x7f || isURLForbidden(c) { r, _ := utf8.DecodeRuneInString(s[i:]) // the character, not its first byte return errors.New("zones: the " + field + " contains " + strconv.QuoteRune(r) + ": " + strconv.Quote(s)) } if c == '%' && (i+2 >= n || !isHex(s[i+1]) || !isHex(s[i+2])) { return errors.New("zones: the " + field + " has a % that is not followed by two hex digits: " + strconv.Quote(s)) } if c == '%' && isUnreserved(unhex(s[i+1])<<4|unhex(s[i+2])) { // %7E and ~ are the same URL (RFC 3986): one spelling, so two // listings cannot be the same endpoint. return errors.New("zones: the " + field + " escapes a character that needs no escaping: " + strconv.Quote(s)) } if c == '&' && isEntity(s[i+1:]) { return errors.New("zones: the " + field + " contains an HTML entity shape: " + strconv.Quote(s)) } } scheme, rest, ok := strings.Cut(s, "://") if !ok { return errors.New("zones: the " + field + " needs a scheme (" + strings.Join(schemes, ", ") + "): " + strconv.Quote(s)) } // A scheme is case-insensitive, so HTTPS:// is https://; Canonical lowercases // it the same way, which is what makes the two one endpoint. known := false for _, sc := range schemes { if strings.ToLower(scheme) == sc { known = true break } } if !known { return errors.New("zones: the " + field + " scheme is " + strconv.Quote(scheme) + ", want one of " + strings.Join(schemes, ", ")) } authority := rest if i := strings.IndexAny(authority, "/?#"); i >= 0 { authority = authority[:i] // A "." or ".." path segment is resolved away by every browser and by // RFC 3986, so it would be a second spelling of another URL. path := rest[i:] if j := strings.IndexByte(path, '?'); j >= 0 { path = path[:j] } for _, seg := range strings.Split(path, "/") { if seg == "." || seg == ".." { return errors.New("zones: the " + field + " has a . or .. path segment: " + strconv.Quote(s)) } } } if strings.Contains(authority, "@") { return errors.New("zones: the " + field + " carries credentials: " + strconv.Quote(s)) } host, port := authority, "" if i := strings.LastIndexByte(authority, ':'); i >= 0 { host, port = authority[:i], authority[i+1:] if err := checkPort(port); err != nil { return errors.New("zones: the " + field + "'s port is 1 to 65535, got " + strconv.Quote(port)) } } if host == "" { return errors.New("zones: the " + field + " has no host: " + strconv.Quote(s)) } if err := checkHost(host); err != nil { return errors.New("zones: the " + field + "'s host is a DNS name or an IPv4 address, got " + strconv.Quote(host)) } return nil } // checkHost accepts a DNS name or a dotted IPv4 address. // // A DNS name is labels of 1 to 63 characters of [A-Za-z0-9-], alphanumeric at // both ends, at most 253 characters in all, with one optional terminal dot. // Anything a browser would parse as IPv4 must BE a valid dotted-quad address: // under the WHATWG URL parser a host whose last label is numeric (decimal, or // 0x hex) is an IPv4 address, so 0x7f.1 opens 127.0.0.1 and a.1 is no URL at // all. A last label starting 0x is held to the address rule even when the rest // is not hex (a browser would take 0xyz as a name): stricter, never looser. // An address takes no terminal dot. Bracketed IPv6 is refused, deliberately: it is the one host shape that // needs characters every other field here refuses, and no zone needs it yet. func checkHost(host string) error { name := strings.TrimSuffix(host, ".") if name == "" || len(name) > 253 { return errors.New("bad host") } labels := strings.Split(name, ".") last := strings.ToLower(labels[len(labels)-1]) if strings.Trim(last, "0123456789") == "" || strings.HasPrefix(last, "0x") { // No terminal dot on an address: browsers accept 1.2.3.4. but Go's // dialer, so gnokey and tm2, look it up as a name and fail. if name != host { return errors.New("bad ipv4") } return checkIPv4(name) } for _, label := range labels { if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { return errors.New("bad host") } for _, r := range label { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-': default: return errors.New("bad host") } } } return nil } func checkIPv4(s string) error { parts := strings.Split(s, ".") if len(parts) != 4 { return errors.New("bad ipv4") } for _, p := range parts { if p == "" || len(p) > 3 || strings.Trim(p, "0123456789") != "" || (len(p) > 1 && p[0] == '0') { return errors.New("bad ipv4") } if n, _ := strconv.Atoi(p); n > 255 { return errors.New("bad ipv4") } } return nil } // checkPort accepts 1 to 65535, decimal digits only, no leading zero. Digits // only because strconv.Atoi takes a sign, and +443 is no port to url.Parse, // to a browser, or to tm2's peer parser. func checkPort(port string) error { if port == "" || strings.Trim(port, "0123456789") != "" || port[0] == '0' || len(port) > 5 { return errors.New("bad port") } if p, _ := strconv.Atoi(port); p > 65535 { return errors.New("bad port") } return nil } // isURLForbidden is the visible ASCII a URL here may not carry: what could // close a markdown link or open a construct around it, and a fragment. func isURLForbidden(c byte) bool { switch c { case '<', '>', '"', '\'', '`', '(', ')', '[', ']', '{', '}', '|', '\\', '^', '#': return true } return false } func isHex(c byte) bool { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') } func unhex(c byte) byte { switch { case c >= '0' && c <= '9': return c - '0' case c >= 'a' && c <= 'f': return c - 'a' + 10 } return c - 'A' + 10 } // isUnreserved is RFC 3986's unreserved set: what a URL never needs to escape. func isUnreserved(c byte) bool { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '.' || c == '_' || c == '~' } // isEntity reports whether s starts with what an HTML entity would follow an // ampersand with: a run of [A-Za-z0-9#] closed by a semicolon. func isEntity(s string) bool { for i := 0; i < len(s); i++ { c := s[i] switch { case c == ';': return i > 0 case (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '#': default: return false } } return false } // TrimSpaces trims spaces (Unicode Zs: U+0020, no-break space, U+3000 and the // like) and tabs from both ends, and nothing else. strings.TrimSpace also // strips line breaks and separators (CR, LF, U+0085, U+2028), which free text // refuses: trimmed first, they would be accepted silently, changed. A line // break a caller types reaches the validator and is refused there. func TrimSpaces(s string) string { return strings.TrimFunc(s, func(r rune) bool { return r == '\t' || unicode.Is(unicode.Zs, r) }) } // lowerAuthority lowercases a URL's scheme and host, both case-insensitive, // and leaves the path and query as typed. Stored that way because gno's link // sanitizer is not case-insensitive (an HTTPS:// link would render with an // empty href), and so every later lowercasing of the host (Canonical, // IsPrivateHost) finds nothing to change: a scan, no copy. ASCII only, see // asciiLower. func lowerAuthority(addr string) string { scheme, rest, ok := strings.Cut(addr, "://") if !ok { return addr } host, tail := rest, "" if i := strings.IndexAny(rest, "/?"); i >= 0 { host, tail = rest[:i], rest[i:] } return asciiLower(scheme) + "://" + asciiLower(host) + tail } // trimEmptyTail drops an empty tail from a URL, by Canonical's own rules: an // empty query's "?" (when it is the tail's only "?") and a bare "/". gnokey // would dial a host with them attached, so an endpoint is not stored with them // (nor a zone's gnoweb and genesis URLs, which trimInfo repairs the same way, as // a browser's address bar writes https://host/; its main RPC is refused with // them, ValidateInfo). Canonical // drops both too, so Canonical(trimEmptyTail(x)) == Canonical(x), and a check // made on what a caller typed holds for what is stored. A path is kept as // typed otherwise ("/?q" stays: Canonical reads it as "?q", a browser too). func trimEmptyTail(addr string) string { scheme, rest, ok := strings.Cut(addr, "://") if !ok { return addr } i := strings.IndexAny(rest, "/?") if i < 0 { return addr } authority, tail := rest[:i], rest[i:] if strings.HasSuffix(tail, "?") && strings.Count(tail, "?") == 1 { tail = tail[:len(tail)-1] } if tail == "/" { tail = "" } return scheme + "://" + authority + tail } // asciiLower lowercases A to Z and nothing else. strings.ToLower also folds // a few non-ASCII letters into ASCII ones (U+212A KELVIN SIGN to k, U+0130 to // i), which would turn a URL validation refuses into one it accepts, under a // spelling the caller never typed. func asciiLower(s string) string { for i := 0; i < len(s); i++ { if c := s[i]; c >= 'A' && c <= 'Z' { b := []byte(s) for j := i; j < len(b); j++ { if b[j] >= 'A' && b[j] <= 'Z' { b[j] += 'a' - 'A' } } return string(b) } } return s } // Canonical is the form two addresses are compared in, so one endpoint cannot // be listed twice under two spellings: the scheme and the host lowercased (both // are case-insensitive), the host's terminal dot dropped, the scheme's default // port dropped (:443 for https and wss, :80 for http and ws), an empty path // before a query dropped, an empty query's "?" and a bare "/" dropped, the hex // digits of every %XX escape uppercased, and for an rpc endpoint tcp:// read as // http://, which is how gnokey dials it. A path or a query that says something // is kept as typed: those are case-sensitive and name different resources. A // peer is lowercased whole and loses its host's terminal dot: its node id is // lowercase bech32 and its host is a name. func Canonical(kind EndpointKind, addr string) string { if kind == Seed || kind == Peer { id, hostport, ok := strings.Cut(asciiLower(addr), "@") if !ok { return asciiLower(addr) } if i := strings.LastIndexByte(hostport, ':'); i >= 0 { hostport = strings.TrimSuffix(hostport[:i], ".") + hostport[i:] } return id + "@" + hostport } scheme, rest, ok := strings.Cut(addr, "://") if !ok { return addr } scheme = asciiLower(scheme) if kind == RPC && scheme == "tcp" { scheme = "http" } i := strings.IndexAny(rest, "/?") if i < 0 { i = len(rest) } authority, tail := asciiLower(rest[:i]), rest[i:] host, port := authority, "" if j := strings.LastIndexByte(authority, ':'); j >= 0 { host, port = authority[:j], authority[j+1:] } host = strings.TrimSuffix(host, ".") switch { case port == "443" && (scheme == "https" || scheme == "wss"), port == "80" && (scheme == "http" || scheme == "ws"): port = "" } if port != "" { host += ":" + port } if strings.HasSuffix(tail, "?") && strings.Count(tail, "?") == 1 { tail = tail[:len(tail)-1] // an empty query, not a query ending in "?" } if strings.HasPrefix(tail, "/?") { tail = tail[1:] } if tail == "/" { tail = "" } return scheme + "://" + host + upperEscapes(tail) } // upperEscapes uppercases the two hex digits of every %XX in s. func upperEscapes(s string) string { if !strings.Contains(s, "%") { return s } b := []byte(s) for i := 0; i+2 < len(b); i++ { if b[i] == '%' { b[i+1] = toUpperHex(b[i+1]) b[i+2] = toUpperHex(b[i+2]) } } return string(b) } func toUpperHex(c byte) byte { if c >= 'a' && c <= 'f' { return c - 'a' + 'A' } return c } // checkPeer accepts a tm2 p2p address: <node id>@<host>:<port>, where the node // id is the node's g1 address, as `gnoland secrets get node_id` prints it. func checkPeer(s string) error { id, hostport, ok := strings.Cut(s, "@") if !ok { return errors.New("zones: a peer is <node id>@<host>:<port>, got " + strconv.Quote(s)) } if len(s) > MaxURLLen { return errors.New("zones: a peer is longer than " + strconv.Itoa(MaxURLLen) + " bytes") } // Lowercase only: bech32 also decodes an all-uppercase id, but tm2 compares // node ids byte for byte when it dials, so an uppercase one never connects. if !ValidAddress(address(id)) { return errors.New("zones: a peer's node id is a lowercase g1 address, got " + strconv.Quote(id)) } i := strings.LastIndexByte(hostport, ':') if i <= 0 { return errors.New("zones: a peer needs a port: " + strconv.Quote(s)) } host, port := hostport[:i], hostport[i+1:] if err := checkPort(port); err != nil { return errors.New("zones: a peer's port is 1 to 65535, got " + strconv.Quote(port)) } if err := checkHost(host); err != nil { return errors.New("zones: a peer's host is a DNS name or an IPv4 address, got " + strconv.Quote(host)) } return nil }
  12. #12zones_test.gno
  13. #13package zones import ( "strings" "testing" "gno.land/p/nt/uassert/v0" ) // A real node id, from mainnet's published persistent_peers. const nodeID = "g15rcv5yqef3kvnmueqvkyw8y05sd40jz9p3n5su" func TestValidateSlug(t *testing.T) { cases := []struct { in, err string }{ {"mainnet", ""}, {"onyx", ""}, {"moul-staging", ""}, {"t1", ""}, {"a", "2 to 32"}, {strings.Repeat("a", 33), "2 to 32"}, {"Mainnet", "[a-z0-9-]"}, {"-onyx", "[a-z0-9-]"}, {"onyx-", "[a-z0-9-]"}, {"on_yx", "[a-z0-9-]"}, {"on/yx", "[a-z0-9-]"}, {"on|yx", "[a-z0-9-]"}, } for _, c := range cases { err := ValidateSlug(c.in) if c.err == "" { uassert.NoError(t, err, c.in) } else { uassert.ErrorContains(t, err, c.err, c.in) } } } func TestValidateChainID(t *testing.T) { uassert.NoError(t, ValidateChainID("gnoland-1")) uassert.NoError(t, ValidateChainID("moulstaging-1")) uassert.NoError(t, ValidateChainID("dev")) uassert.NoError(t, ValidateChainID("test_5.x")) uassert.ErrorContains(t, ValidateChainID(""), "1 to 50") uassert.ErrorContains(t, ValidateChainID(strings.Repeat("x", 51)), "1 to 50") uassert.ErrorContains(t, ValidateChainID("gno land"), "[A-Za-z0-9._-]") uassert.ErrorContains(t, ValidateChainID("a|b"), "[A-Za-z0-9._-]") } func TestValidateEndpoint(t *testing.T) { cases := []struct { kind EndpointKind addr string err string }{ {RPC, "https://rpc.gno.land", ""}, {RPC, "https://rpc.gno.land:443", ""}, {RPC, "http://127.0.0.1:26657", ""}, {RPC, "tcp://127.0.0.1:26657", ""}, {RPC, "wss://rpc.gno.land/websocket", ""}, {Gnoweb, "https://gno.land", ""}, {Indexer, "https://indexer.gno.land/graphql/query", ""}, {Faucet, "https://onyx.testnets.gno.land/faucet", ""}, {Explorer, "https://gnoscan.io/?chainId=onyx-1", ""}, {Seed, nodeID + "@seed-1.gno.land:26656", ""}, {Peer, nodeID + "@10.0.0.1:26656", ""}, {RPC, "", "is empty"}, {RPC, "rpc.gno.land", "needs a scheme"}, {RPC, "ftp://rpc.gno.land", "scheme is"}, {Gnoweb, "tcp://gno.land", "scheme is"}, {RPC, "https://", "has no host"}, {RPC, "https:///path", "has no host"}, {RPC, "https://user:pass@rpc.gno.land", "credentials"}, {Gnoweb, "https://gno.land/a b", "contains"}, {Gnoweb, "https://gno.land/)evil", "contains"}, {Gnoweb, "https://gno.land/[x]", "contains"}, {Gnoweb, "https://gno.land/\"x", "contains"}, {Gnoweb, "https://gno.land/é", "contains"}, {Gnoweb, "https://gno.land/" + strings.Repeat("a", MaxURLLen), "longer than"}, {RPC, "https://:26657", "has no host"}, {RPC, "https://host:not-a-port", "port is 1 to 65535"}, {RPC, "https://host:99999", "port is 1 to 65535"}, {RPC, "https://host:", "port is 1 to 65535"}, {RPC, "https://ho_st", "DNS name"}, {RPC, "https://[::1]:26657", "contains"}, {RPC, "https://rpc.gno.land:443/path?x=1", ""}, {RPC, "https://rpc.gno.land:443/path?x=1#f", "contains"}, // a fragment is never sent: no second listing {RPC, "https://rpc.gno.land:+443", "port is 1 to 65535"}, {Gnoweb, "https://gno.land/%zz", "two hex digits"}, {Gnoweb, "https://gno.land/%2", "two hex digits"}, {Gnoweb, "https://gno.land/%2F", ""}, {Gnoweb, "https://gno.land/?a=1&b=2", ""}, {Gnoweb, "https://gno.land/?a=1&amp;b=2", "entity"}, {Gnoweb, "https://0x7f.1", "DNS name"}, {Gnoweb, "https://a.1", "DNS name"}, {Gnoweb, "https://foo.123", "DNS name"}, {Gnoweb, "https://1.2.3.4", ""}, {Peer, "G15RCV5YQEF3KVNMUEQVKYW8Y05SD40JZ9P3N5SU@seed-1.gno.land:26656", "lowercase g1 address"}, {Peer, nodeID + "@seed-1.gno.land:+26656", "1 to 65535"}, {Peer, nodeID + "@" + strings.Repeat("a.", 120) + "example:26656", "longer than"}, {RPC, "https://rpc.gno.land.", ""}, {RPC, "http://10.0.0.1:26657", ""}, {RPC, "http://localhost:26657", ""}, {RPC, "https://-.example.com", "DNS name"}, {RPC, "https://bad-.example.com", "DNS name"}, {RPC, "https://-bad.example.com", "DNS name"}, {RPC, "https://foo..example.com", "DNS name"}, {RPC, "https://.example.com", "DNS name"}, {RPC, "https://example..", "DNS name"}, {RPC, "https://" + strings.Repeat("a", 64) + ".example.com", "DNS name"}, {RPC, "https://" + strings.Repeat("a", 63) + ".example.com", ""}, {RPC, "http://300.1.1.1", "DNS name"}, {RPC, "http://1.2.3", "DNS name"}, {RPC, "http://01.2.3.4", "DNS name"}, {Peer, nodeID + "@bad-.example.com:26656", "DNS name"}, {Peer, "seed-1.gno.land:26656", "<node id>@"}, {Peer, "nope@seed-1.gno.land:26656", "g1 address"}, {Peer, nodeID + "@seed-1.gno.land", "needs a port"}, {Peer, nodeID + "@:26656", "needs a port"}, {Peer, nodeID + "@seed-1.gno.land:0", "1 to 65535"}, {Peer, nodeID + "@seed-1.gno.land:65536", "1 to 65535"}, {Peer, nodeID + "@seed-1.gno.land:080", "1 to 65535"}, {Peer, nodeID + "@seed-1.gno.land:port", "1 to 65535"}, {Seed, nodeID + "@seed_1.gno.land:26656", "DNS name"}, {Seed, nodeID + "@[::1]:26656", "DNS name"}, {RPC, "HTTPS://rpc.gno.land", ""}, {Gnoweb, "Https://gno.land", ""}, {" rpc ", "not a url", "unknown endpoint kind"}, {"RPC", "https://rpc.gno.land", "unknown endpoint kind"}, {"", "https://gno.land", "needs a kind"}, {"grpc", "https://gno.land", "unknown endpoint kind"}, } for _, c := range cases { err := ValidateEndpoint(c.kind, c.addr) label := string(c.kind) + " " + c.addr if c.err == "" { uassert.NoError(t, err, label) } else { uassert.ErrorContains(t, err, c.err, label) } } } func TestValidateInfo(t *testing.T) { ok := Info{ChainID: "onyx-1", Title: "Onyx", Kind: Testnet, RPCURL: "https://rpc.onyx.testnets.gno.land"} uassert.NoError(t, ValidateInfo(ok)) // A loopback main RPC is a local zone's, and only a local zone's: it names a // different machine for every reader. tcp := ok tcp.RPCURL = "tcp://127.0.0.1:26657" uassert.ErrorContains(t, ValidateInfo(tcp), "private or special-use") tcp.Kind = Local uassert.NoError(t, ValidateInfo(tcp)) for _, h := range []string{"http://localhost:26657", "http://10.0.0.1:26657", "http://192.168.1.2", "http://172.20.0.1", "http://169.254.169.254", "http://100.64.0.1", "http://0.0.0.0:26657", "http://api.localhost"} { in := ok in.RPCURL = h uassert.ErrorContains(t, ValidateInfo(in), "private or special-use", h) } pub := ok pub.RPCURL = "http://172.32.0.1" uassert.NoError(t, ValidateInfo(pub)) bad := func(mut func(*Info), want string) { in := ok mut(&in) uassert.ErrorContains(t, ValidateInfo(in), want) } bad(func(in *Info) { in.ChainID = "" }, "chain id") bad(func(in *Info) { in.Title = "" }, "title is 1 to 64") bad(func(in *Info) { in.Title = strings.Repeat("x", MaxTitleLen+1) }, "title is longer than 64") bad(func(in *Info) { in.Title = "two\nlines" }, "control character") bad(func(in *Info) { in.Title = "\u200b" }, "invisible or bidi") bad(func(in *Info) { in.Title = "\u2060" }, "invisible or bidi") bad(func(in *Info) { in.Title = "\u3164" }, "invisible or bidi") bad(func(in *Info) { in.Title = "\u061cOnyx" }, "invisible or bidi") bad(func(in *Info) { in.Title = "Onyx\U000e0041" }, "invisible or bidi") bad(func(in *Info) { in.Title = "\u2800" }, "invisible or bidi") bad(func(in *Info) { in.Title = "x\u17b4" }, "invisible or bidi") // Khmer inherent vowels draw nothing bad(func(in *Info) { in.Description = "\u0301" }, "nothing visible") // optional, but not blank bad(func(in *Info) { in.Title = "\ue000 Onyx" }, "which this chain does not draw") bad(func(in *Info) { in.Title = "a\u180bb" }, "invisible or bidi") // a Mongolian selector after a Latin letter bad(func(in *Info) { in.Title = "Onyx\ufe0f" }, "invisible or bidi") // after a letter it modifies nothing bad(func(in *Info) { in.Title = "a\U000e0100" }, "invisible or bidi") for _, r := range []rune{0x115F, 0x1160, 0xFFA0, 0x17B5, 0x13441, 0x13442, 0x16FE4} { r := r bad(func(in *Info) { in.Title = "on" + string(r) + "yx" }, "invisible or bidi") } // Enclosing marks draw a badge's frame around any character. for _, s := range []string{"!\u20e4 flagged", "v\u20de verified", "x\u20e0", "x\u20dd"} { s := s bad(func(in *Info) { in.Title = s }, "enclosing mark") } // √ is maths, not a badge; × is everyday text. A variation selector after a // base it modifies is text: an emoji heart as a phone writes it, a // Mongolian free variant, an ideographic variation sequence. for _, s := range []string{"fees scale with \u221an", "1920\u00d71080", "made with \u2764\ufe0f", "\u2764\ufe0e text heart", "\u2211\ufe0f", "\u203c\ufe0f new", "\u2049\ufe0f", "\u2139\ufe0f", "\u3030\ufe0f", "\u303d\ufe0f", "\u18aa\u180b", "\u1828\u180b\u1821", "\u845b\U000e0100"} { good := ok good.Description = s uassert.NoError(t, ValidateInfo(good), s) } for _, r := range []rune{0x2713, 0x2714, 0x2611, 0x1F5F8, 0x1F5F9, 0x1F197, 0x2716, 0x2717, 0x2718, 0x2715, 0x274E, 0x2612, 0x1F6AB, 0x26D4, 0x1F6D1, 0x1F5F4, 0x1F5F7, 0x231B, 0x23F8, 0x23FA, 0x2705, 0x26A0, 0x23F3, 0x274C, 0x23F9, 0x1FBB1, 0x237B, 0x10102, 0x1F5D9, 0x2A2F, 0x2573, 0x2BBD, 0x2BBF, 0x29D6, 0x29D7, 0x1F7A8, 0x1F7AE, 0x2613, 0x2A09, 0x22A0, 0x2327, 0x1F147, 0x1F187, 0x2297, 0x2A02, 0x1F167, 0x1F6C7, 0x2298, 0x29B8, 0x24CD, 0x24E7, 0x24B3, 0x1F127, 0x29BB, 0x2A34, 0x2A35, 0x2A37, 0x292B, 0x292C, 0x1F532, 0x1F533, 0x26DD, 0x1F5BE, 0x2B59, 0x2A36, 0x26D2, 0x1FBC0, 0x1D145, 0x26CC, 0x2A3B, 0x2BBE, 0x1F5F5, 0x1F5F6} { r := r bad(func(in *Info) { in.Title = string(r) + " official" }, "status glyph") } bad(func(in *Info) { in.Title = "\u2705 official mainnet" }, "status glyph") bad(func(in *Info) { in.Title = "Z\u0301\u0301\u0301\u0301\u0301" }, "combining marks") // The limit is a run: four marks pass, and a base character ends a run, so // two runs of three are not a stack of six. A spacing mark is not a stack // either, so real words in these scripts pass. // A spacing mark (U+093E) between two runs of four ends the first run. for _, word := range []string{"Z\u0301\u0301\u0301\u0301", "a\u0301\u0301\u0301b\u0301\u0301\u0301", "\u0915\u0301\u0301\u0301\u0301\u093e\u0301\u0301\u0301\u0301", "ज़िंदगी", "ਜ਼ਿੰਦਗੀ", "ကြိုး", "སྒྲུབ"} { good := ok good.Title = word uassert.NoError(t, ValidateInfo(good), word) } bad(func(in *Info) { in.Title = "\u0301" }, "nothing visible") bad(func(in *Info) { in.Title = "bad\xffbyte" }, "not valid UTF-8") bad(func(in *Info) { in.Title = "alpha\u2028beta" }, "control character") bad(func(in *Info) { in.Title = "alpha\u2029beta" }, "control character") bad(func(in *Info) { in.Title = "alpha\u0085beta" }, "control character") bad(func(in *Info) { in.Description = "x\u009by" }, "control character") bad(func(in *Info) { in.Title = "Onyx\u202e" }, "invisible or bidi") bad(func(in *Info) { in.Description = "fine\u200dtext" }, "invisible or bidi") bad(func(in *Info) { in.Description = strings.Repeat("x", MaxDescriptionLen+1) }, "description is longer than 512") bad(func(in *Info) { in.Kind = "" }, "needs a kind") bad(func(in *Info) { in.Kind = "betanet" }, "unknown kind") bad(func(in *Info) { in.RPCURL = "" }, "rpc url is empty") // The main RPC is what gnokey -remote gets, which dials http, https, tcp. bad(func(in *Info) { in.RPCURL = "wss://rpc.onyx.testnets.gno.land/websocket" }, "rpc url scheme") bad(func(in *Info) { in.RPCURL = "ws://rpc.onyx.testnets.gno.land/websocket" }, "rpc url scheme") bad(func(in *Info) { in.RPCURL = "https://rpc.example.com/path" }, "no path") bad(func(in *Info) { in.RPCURL = "https://rpc.example.com?x=1" }, "no path") bad(func(in *Info) { in.RPCURL = "https://rpc.example.com:443#f" }, "contains") bad(func(in *Info) { in.RPCURL = "https://rpc.example.com/" }, "no path") bad(func(in *Info) { in.Kind = " testnet " }, "unknown kind") port := ok port.RPCURL = "https://rpc.onyx.testnets.gno.land:443" uassert.NoError(t, ValidateInfo(port)) bad(func(in *Info) { in.GnowebURL = "gno.land" }, "gnoweb url needs a scheme") bad(func(in *Info) { in.GenesisURL = "tcp://x" }, "genesis url scheme") // Runes, not bytes: a 64-character title of multi-byte runes is fine. in := ok in.Title = strings.Repeat("é", MaxTitleLen) uassert.NoError(t, ValidateInfo(in)) } func TestCanonical(t *testing.T) { uassert.Equal(t, "https://rpc.gno.land/API?Q=1", Canonical(RPC, "HTTPS://RPC.Gno.Land/API?Q=1")) uassert.Equal(t, "https://rpc.gno.land", Canonical(RPC, "https://RPC.gno.land")) // One endpoint, one key: every spelling below is the same server. for _, same := range []string{"https://rpc.gno.land", "https://rpc.gno.land/", "https://rpc.gno.land.", "https://rpc.gno.land:443", "https://rpc.gno.land?", "HTTPS://RPC.gno.land.:443/"} { uassert.Equal(t, "https://rpc.gno.land", Canonical(RPC, same), same) } uassert.Equal(t, "http://h.example.com", Canonical(Gnoweb, "http://h.example.com:80")) uassert.Equal(t, "http://h.example.com:443", Canonical(Gnoweb, "http://h.example.com:443")) // not http's default uassert.Equal(t, "https://h.example.com/api", Canonical(Indexer, "https://h.example.com:443/api")) uassert.Equal(t, nodeID+"@seed-1.gno.land:26656", Canonical(Peer, nodeID+"@SEED-1.gno.land:26656")) } func TestParse(t *testing.T) { for _, s := range []string{"", "approved", "pending", "rejected", "retired"} { _, err := ParseStatus(s) uassert.NoError(t, err, s) } _, err := ParseStatus("official") uassert.ErrorContains(t, err, "unknown status") _, err = ParseKind("betanet") uassert.ErrorContains(t, err, "unknown kind") k, err := ParseKind(" testnet ") uassert.NoError(t, err) uassert.Equal(t, string(Testnet), string(k)) _, err = ParseEndpointKind("grpc") uassert.ErrorContains(t, err, "unknown endpoint kind") _, err = ParseVerification("trusted") uassert.ErrorContains(t, err, "unknown verification") // Every listed value parses back to itself. for _, v := range EndpointKinds() { got, err := ParseEndpointKind(string(v)) uassert.NoError(t, err) uassert.Equal(t, string(v), string(got)) } for _, v := range Kinds() { got, _ := ParseKind(string(v)) uassert.Equal(t, string(v), string(got)) } for _, v := range Statuses() { got, _ := ParseStatus(string(v)) uassert.Equal(t, string(v), string(got)) } for _, v := range Verifications() { got, _ := ParseVerification(string(v)) uassert.Equal(t, string(v), string(got)) } }
#2AddPackagegno.land/r/moul/x/kitindexdemo/v013 arguments
Attached funds
10000000ugnot

Arguments · 13

  1. #1kitindexdemo
  2. #2README.md
  3. #3# kitindexdemo A notes board whose only job is to show [`p/moul/kit/index`](../../../../p/moul/kit/index) doing the thing a realm with a store always ends up needing: answering "every note tagged `gno`" as cheaply as it answers "note 7". No logic of its own. The records are a [`kit/store`](../../../../p/moul/kit/store), the two lookups are `kit/index`, the page is [`kit/ui`](../../../../p/moul/kit/ui). ## What is worth reading **Every write touches the store and both indexes in ONE function.** That is the entire correctness argument for keeping three containers apart instead of reaching for a multi-index store: an abort anywhere in `Post` leaves none of the three applied, and `Retract` unwinds all three together. **The tag is validated at write time, the body is escaped at render time**, and the difference is the point. A tag is also an index key and a path segment, so a tag carrying a pipe or a slash breaks the table and the URL as well as the sentence; `[a-z0-9-]`, checked once, is narrower and cheaper than escaping it in three places. A body is prose, so it goes through `ui.Cell` at the one call site that renders it. **The root page is pinned with `uassert.Equal`, not an example.** It carries two consecutive blank lines, and an `// Output:` block can never pin those: gno collapses them, exactly as Go does. The pages without that shape get an `ExampleRender`. ## Pages | path | shows | |---|---| | `` | every tag with its count, paged | | `tag/<tag>` | the notes under one tag | | `author/<address>` | the notes by one address | ## Functions | | | |---|---| | `Post(cur, tag, body) int64` | files a note, returns its id | | `Retract(cur, id)` | removes one, author only | `MaxBody` is 280 bytes and `MaxTagLen` is 24, because every byte stored locks a storage deposit this realm's address pays and an unbounded write is an unbounded bill a stranger chooses the size of. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **On mainnet:** [![deployment status](https://gnoscope.com/_badges/shield/status/r/moul/x/kitindexdemo/v0?network=mainnet)](https://gnoscope.com/realm/r/moul/x/kitindexdemo/v0) [![transactions](https://gnoscope.com/_badges/shield/txs/r/moul/x/kitindexdemo/v0?network=mainnet)](https://gnoscope.com/realm/r/moul/x/kitindexdemo/v0) [![unique callers](https://gnoscope.com/_badges/shield/users/r/moul/x/kitindexdemo/v0?network=mainnet)](https://gnoscope.com/realm/r/moul/x/kitindexdemo/v0) [![deployed revision](https://gnoscope.com/_badges/shield/version/r/moul/x/kitindexdemo/v0?network=mainnet)](https://gnoscope.com/realm/r/moul/x/kitindexdemo/v0) **Dependency graph:** ![gno.land/r/moul/x/kitindexdemo/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/x/kitindexdemo/v0/deps.png) > 🧪 **Highly experimental — potentially vibe-coded.** Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
  4. #4example_test.gno
  5. #5package kitindexdemo import ( "testing" "gno.land/p/nt/uassert/v0" ) // seed builds a fixed board without crossing anything, so an example can set up // its own state. // // An Example has no realm token, so it cannot call Post, and the state the // tests happen to leave is not a contract. Writing the globals directly is the // escape hatch AGENTS.md names: same package, no crossing, fully deterministic. func seed() { reset() add := func(who address, tag, body string, height int64) { id := notes.Add(&note{Author: who, Tag: tag, Body: body, Height: height}) if err := byTag.Add(tag, id); err != nil { panic(err) } if err := byAuthor.Add(who.String(), id); err != nil { panic(err) } } add(alice, "gno", "the index is two containers and one function", 123) add(bob, "gno", "and the function is where the correctness is", 124) add(alice, "storage", "you are charged for objects, not for data", 125) } // The root page carries TWO consecutive blank lines, which an // Output: block // can never pin: gno collapses them, exactly as Go does. So the root is // asserted with uassert.Equal and a backtick literal, which preserves them, // and only the pages without that shape get an Example. // // This is the order of preference AGENTS.md names, and the root page is the // case that forces the second rung. func TestRenderRoot(t *testing.T) { seed() want := `# Notes by tag 3 note(s) under 2 tag(s), by 2 author(s). | tag | notes | | --- | --- | | [gno](/r/moul/x/kitindexdemo/v0:tag/gno) | 2 | | [storage](/r/moul/x/kitindexdemo/v0:tag/storage) | 1 | [Post a note](/r/moul/x/kitindexdemo/v0$help&func=Post&body=&tag=) ` uassert.Equal(t, want, Render("")) } // ExampleRender_tag pins one tag's page, which is the lookup the library is for. func ExampleRender_tag() { seed() print(Render("tag/gno")) // Output: // # #gno // | # | author | tag | note | height | // | --- | --- | --- | --- | --- | // | 1 | `g1v9kxjc…40gh` | `gno` | the index is two containers and one function | 123 | // | 2 | `g1vfhkyh…vdhu` | `gno` | and the function is where the correctness is | 124 | }
  6. #6gnomod.toml
  7. #7module = "gno.land/r/moul/x/kitindexdemo/v0" gno = "0.9" private = true
  8. #8kitindexdemo.gno
  9. #9// untrusted-render: the tag is charset-checked to [a-z0-9-] at write time and // is the only stored string interpolated raw; the body is a caller's text and // goes through ui.Cell at every call site that renders it. // Package kitindexdemo is a notes board whose only job is to show // gno.land/p/moul/kit/index doing the thing a realm with a store always ends // up needing: answering "every note tagged gno" as cheaply as it answers // "note 7". // // There is no logic of its own here. The records are a kit/store, the two // lookups are kit/index, the page is kit/ui, and what is worth reading is the // shape: every write touches the store and both indexes in ONE function, which // is the entire correctness argument for keeping them in separate containers. // // Demo of the p/moul/kit/index library. package kitindexdemo import ( "strconv" "strings" "chain/runtime" "gno.land/p/moul/kit/index/v0" "gno.land/p/moul/kit/store/v0" ) // MaxBody is the longest note this realm accepts, in bytes. // // A bound rather than none: every byte stored here locks a storage deposit, // paid by whoever signs the write and refunded to whoever signs the delete // (EFFECTIVE_GNO.md section 9.2), and an unbounded write is an unbounded // state the realm carries forever. const MaxBody = 280 // MaxTagLen bounds the index key for the same reason, and small because a tag // is a label rather than a sentence. const MaxTagLen = 24 // PageSize is how many tags the root page shows. const PageSize = 20 // MaxNotes and MaxPerAuthor bound the WHOLE board, which MaxBody does not. // // A per-call length limit bounds one write and nothing else: a caller posting // one-byte notes in a loop grows the store and both indexes without limit. "An unbounded container a third party can grow is an // unbounded storage deposit someone is paying for" is a rule this realm exists // to demonstrate, so it had better obey it. // // Two caps rather than one, and what each buys is narrower than it looks. // MaxNotes bounds the state, which is the property this realm promises. // MaxPerAuthor stops one ADDRESS, not one actor: addresses are free, so fifty // fresh ones at twenty notes each still fill the board and lock everyone else // out until somebody retracts. Keeping a board open to strangers needs // something a griefer cannot mint for free, a deposit or an expiry, which this // demo does not carry. Retracting frees a slot, so neither cap is a one-way door. // // MaxPerAuthor is answered by byAuthor.Count, which is the index doing the job // it was added for. const ( MaxNotes = 1000 MaxPerAuthor = 20 ) type note struct { Author address Tag string Body string Height int64 } var ( notes = store.Named("note") byTag index.Index byAuthor index.Index ) // Post files a note under a tag and returns its id. // // The store write and both index writes happen here, in this order, in one // frame. An abort anywhere in it leaves none of them applied, which is what // makes three containers safe to keep apart. func Post(cur realm, tag, body string) int64 { who := caller(cur) tag = normaliseTag(tag) assertBody(body) assertRoom(who) id := notes.Add(&note{ Author: who, Tag: tag, Body: body, Height: runtime.ChainHeight(), }) mustIndex(byTag.Add(tag, id)) mustIndex(byAuthor.Add(who.String(), id)) return int64(id) } // Retract removes a note. Only its author may, and the store and both indexes // are unwound together. func Retract(cur realm, id int64) { who := caller(cur) sid, ok := store.ParseID(strconv.FormatInt(id, 10)) if !ok { panic("kitindexdemo: not an id: " + strconv.FormatInt(id, 10)) } n, ok := notes.Get(sid) if !ok { panic("kitindexdemo: note #" + sid.String() + " not found") } rec := n.(*note) if rec.Author != who { panic("kitindexdemo: note #" + sid.String() + " is not yours") } notes.Remove(sid) byTag.Remove(rec.Tag, sid) byAuthor.Remove(rec.Author.String(), sid) } // caller is the one place this realm decides who is acting: the realm token is // checked before it is walked, because an unchecked token is not a caller. func caller(cur realm) address { if !cur.IsCurrent() { panic("kitindexdemo: spoofed realm") } return cur.Previous().Address() } func mustIndex(err error) { if err != nil { panic(err) } } // normaliseTag lowercases and validates the tag. // // Validated at WRITE time rather than escaped at render time, deliberately: a // tag is also an index key and a path segment, so a tag that could carry a // pipe or a slash would break the table and the URL as well as the page. The // charset is the narrowest thing that still reads as a label. func normaliseTag(tag string) string { tag = strings.ToLower(strings.TrimSpace(tag)) if tag == "" || len(tag) > MaxTagLen { panic("kitindexdemo: a tag is 1 to " + strconv.Itoa(MaxTagLen) + " characters") } for _, r := range tag { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-': default: panic("kitindexdemo: a tag is [a-z0-9-], got " + strconv.Quote(tag)) } } return tag } // assertRoom refuses a write the board has no room for, globally or for this // address. Checked before the store write, so a refusal costs the caller the // gas of three reads and nothing is half-applied. func assertRoom(who address) { if notes.Len() >= MaxNotes { panic("kitindexdemo: the board is full at " + strconv.Itoa(MaxNotes) + " notes; retract one to free a slot") } if n := byAuthor.Count(who.String()); n >= MaxPerAuthor { panic("kitindexdemo: " + who.String() + " already has " + strconv.Itoa(n) + " notes, the limit is " + strconv.Itoa(MaxPerAuthor)) } } func assertBody(body string) { if strings.TrimSpace(body) == "" { panic("kitindexdemo: the body is empty") } if len(body) > MaxBody { panic("kitindexdemo: the body is " + strconv.Itoa(len(body)) + " bytes, the limit is " + strconv.Itoa(MaxBody)) } }
  10. #10kitindexdemo_test.gno
  11. #11package kitindexdemo import ( "strconv" "strings" "testing" "gno.land/p/moul/kit/index/v0" "gno.land/p/moul/kit/store/v0" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" "gno.land/p/nt/urequire/v0" ) var ( alice = testutils.TestAddress("alice") bob = testutils.TestAddress("bob") ) // reset puts the realm back to its init() state. // // Realm globals persist for the whole test binary and examples run after every // Test, so without this an ExampleRender's pinned output depends silently on // which tests ran before it. Assigning the globals directly is allowed here: // same package, and it crosses nothing. func reset() { notes = store.Named("note") byTag = index.Index{} byAuthor = index.Index{} } func TestPostIndexesInBothDirections(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(alice)) id := Post(cross(cur), "gno", "first") uassert.Equal(t, 1, notes.Len()) uassert.Equal(t, 1, byTag.Count("gno")) uassert.Equal(t, 1, byAuthor.Count(alice.String())) got := byTag.Lookup("gno") urequire.Equal(t, 1, len(got)) uassert.Equal(t, store.ID(id).String(), got[0].String()) } func TestTagIsNormalisedAtWriteTime(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(alice)) Post(cross(cur), " GNO ", "mixed case and spaces") uassert.Equal(t, 1, byTag.Count("gno")) uassert.Equal(t, 1, byTag.Keys()) } // The tag is an index key AND a path segment, so it is validated rather than // escaped: a tag carrying a pipe or a slash breaks the table and the URL, not // only the sentence. func TestTagRefusals(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(alice)) for _, tc := range []struct { name string tag string want string }{ {"empty", "", "a tag is 1 to 24 characters"}, {"blank", " ", "a tag is 1 to 24 characters"}, {"too long", "aaaaaaaaaaaaaaaaaaaaaaaaa", "a tag is 1 to 24 characters"}, {"a pipe", "a|b", "a tag is [a-z0-9-]"}, {"a slash", "a/b", "a tag is [a-z0-9-]"}, {"markdown", "a]b", "a tag is [a-z0-9-]"}, {"a space inside", "a b", "a tag is [a-z0-9-]"}, } { t.Run(tc.name, func(t *testing.T) { uassert.AbortsContains(t, cur, tc.want, func() { Post(cross(cur), tc.tag, "body") }) }) } uassert.Equal(t, 0, notes.Len()) } func TestBodyRefusals(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "the body is empty", func() { Post(cross(cur), "gno", " ") }) long := "" for i := 0; i <= MaxBody; i++ { long += "x" } uassert.AbortsContains(t, cur, "the limit is 280", func() { Post(cross(cur), "gno", long) }) uassert.Equal(t, 0, notes.Len()) } func TestOnlyTheAuthorMayRetract(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(alice)) id := Post(cross(cur), "gno", "alice's note") testing.SetRealm(testing.NewUserRealm(bob)) uassert.AbortsContains(t, cur, "is not yours", func() { Retract(cross(cur), id) }) uassert.Equal(t, 1, notes.Len()) testing.SetRealm(testing.NewUserRealm(alice)) Retract(cross(cur), id) uassert.Equal(t, 0, notes.Len()) } // The store and both indexes have to come apart together, or a later Render // walks ids the store no longer holds. func TestRetractUnwindsEveryContainer(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(alice)) id := Post(cross(cur), "gno", "one") Post(cross(cur), "gno", "two") Retract(cross(cur), id) uassert.Equal(t, 1, notes.Len()) uassert.Equal(t, 1, byTag.Count("gno")) uassert.Equal(t, 1, byAuthor.Count(alice.String())) } func TestRetractRefusals(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "not found", func() { Retract(cross(cur), 99) }) uassert.AbortsContains(t, cur, "not an id", func() { Retract(cross(cur), 0) }) uassert.AbortsContains(t, cur, "not an id", func() { Retract(cross(cur), -1) }) } // A body is a caller's markdown and must not be able to write the page around // it. ui.Cell is what stops a pipe from opening a column. func TestARenderedBodyCannotBreakTheTable(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(alice)) Post(cross(cur), "gno", "a | b [x](http://evil)") got := Render("tag/gno") uassert.False(t, strings.Contains(got, "| a | b ["), "the raw body must not reach the table") uassert.True(t, strings.Contains(got, "\\|"), "the pipe must be escaped") } func TestRenderOfAnUnknownPage(t *testing.T) { reset() uassert.True(t, strings.Contains(Render("nope"), "Not found")) uassert.True(t, strings.Contains(Render("tag"), "No tag given.")) uassert.True(t, strings.Contains(Render("author"), "No author given.")) } // MaxBody bounds one write. These bound the bill, which is the thing the realm // actually pays. func TestPerAuthorCap(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(alice)) for i := 0; i < MaxPerAuthor; i++ { Post(cross(cur), "gno", "note "+strconv.Itoa(i)) } uassert.AbortsContains(t, cur, "the limit is 20", func() { Post(cross(cur), "gno", "one too many") }) // Another address is unaffected, which is the whole reason the cap is // per author and not only global. testing.SetRealm(testing.NewUserRealm(bob)) Post(cross(cur), "gno", "bob still fits") uassert.Equal(t, MaxPerAuthor+1, notes.Len()) } // Retracting frees a slot, so the cap is not a one-way door. func TestRetractingFreesASlot(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(alice)) first := Post(cross(cur), "gno", "note 0") for i := 1; i < MaxPerAuthor; i++ { Post(cross(cur), "gno", "note "+strconv.Itoa(i)) } uassert.AbortsContains(t, cur, "the limit is 20", func() { Post(cross(cur), "gno", "blocked") }) Retract(cross(cur), first) Post(cross(cur), "gno", "now it fits") uassert.Equal(t, MaxPerAuthor, notes.Len()) } // The page number is user input on the Render path. One outside 1..Pages is // clamped into it, so it never renders an empty board under a footer that // says "page -1 of 2". func TestRenderClampsThePageNumber(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(alice)) for i := 0; i < PageSize; i++ { Post(cross(cur), "t"+strconv.Itoa(i), "x") } testing.SetRealm(testing.NewUserRealm(bob)) Post(cross(cur), "t-last", "x") urequire.Equal(t, 2, byTag.Pages(PageSize)) first, last := Render("?page=1"), Render("?page=2") uassert.Equal(t, first, Render("?page=-1")) uassert.Equal(t, first, Render("?page=0")) uassert.Equal(t, last, Render("?page=999")) uassert.True(t, strings.Contains(last, "page 2 of 2")) } // Links are absolute. gnoweb serves this realm at .../kitindexdemo/v0, so a // "./kitindexdemo:tag/x" link resolves to .../kitindexdemo/kitindexdemo:tag/x, // a package that does not exist. func TestRenderLinksAreAbsolute(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(alice)) for i := 0; i <= PageSize; i++ { if i == MaxPerAuthor { testing.SetRealm(testing.NewUserRealm(bob)) } Post(cross(cur), "t"+strconv.Itoa(i), "x") } out := Render("") uassert.True(t, strings.Contains(out, "](/r/moul/x/kitindexdemo/v0:tag/t0)"), out) uassert.True(t, strings.Contains(out, "](/r/moul/x/kitindexdemo/v0:?page=2)"), out) uassert.False(t, strings.Contains(out, "](./"), out) } // realmURL must follow the module line, or a version bump ships dead links. func TestRealmURLMatchesTheModule(cur realm, t *testing.T) { uassert.Equal(t, "gno.land"+realmURL, cur.PkgPath()) } // The global cap is what bounds the realm's whole storage bill, so its // refusal is pinned, and pinned as refusing BEFORE any write: the store and // both indexes are exactly as full as they were. func TestTheBoardIsFull(cur realm, t *testing.T) { reset() for i := 0; i < MaxNotes; i++ { notes.Add(&note{Author: bob, Tag: "gno", Body: "x"}) } testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "the board is full", func() { Post(cross(cur), "gno", "one too many") }) uassert.Equal(t, MaxNotes, notes.Len()) uassert.Equal(t, 0, byTag.Len()) uassert.Equal(t, 0, byAuthor.Count(alice.String())) }
  12. #12render.gno
  13. #13package kitindexdemo import ( "strconv" "gno.land/p/moul/kit/store/v0" "gno.land/p/moul/kit/ui/v0" "gno.land/p/moul/md/v0" "gno.land/p/moul/realmpath/v0" ) // realmURL is this realm as gnoweb serves it. Links are absolute because the // path ends in /v0: a relative "./kitindexdemo:tag/x" resolves against the // parent directory to .../kitindexdemo/kitindexdemo:tag/x, which does not // exist. Not derived at runtime: a Render has no cur, and CurrentRealm there // is the caller. TestRealmURLMatchesTheModule pins it to the module line. const realmURL = "/r/moul/x/kitindexdemo/v0" // Render is the whole public surface. Three pages: the tag list, one tag, one // author. // // Every row goes through ui.NewTable rather than concatenated pipes, and every // caller string through ui.Cell, because a public path is immutable and a // Render that ships wrong ships forever. func Render(path string) string { req := realmpath.Parse(path) switch req.PathPart(0) { case "": return renderIndex(req) case "tag": return renderTag(req.PathPart(1)) case "author": return renderAuthor(req.PathPart(1)) default: return md.H1("Not found") + ui.Empty("No such page.") } } func renderIndex(req *realmpath.Request) string { // The page number is user input: clamp it into 1..pages, or ?page=-1 // renders an empty board under a footer saying "page -1 of 2". pages := byTag.Pages(PageSize) page := 1 if n, err := strconv.Atoi(req.Query.Get("page")); err == nil { page = n } if page < 1 { page = 1 } if page > pages { page = pages } t := ui.NewTable("tag", "notes") for _, e := range byTag.Page(page, PageSize) { t.Row( md.Link(e.Key, realmURL+":tag/"+e.Key), strconv.Itoa(len(e.IDs)), ) } return ui.Join("\n", md.H1("Notes by tag"), md.Paragraph(strconv.Itoa(notes.Len())+" note(s) under "+ strconv.Itoa(byTag.Keys())+" tag(s), by "+ strconv.Itoa(byAuthor.Keys())+" author(s)."), t.OrEmpty("Nothing posted yet."), pager(page, pages), md.Paragraph(ui.Action("Post a note", "Post", "tag", "", "body", "")), ) } func renderTag(tag string) string { if tag == "" { return md.H1("Not found") + ui.Empty("No tag given.") } return md.H1("#"+ui.Cell(tag)) + notesTable(byTag.Lookup(tag), "No notes under this tag.") } func renderAuthor(addr string) string { if addr == "" { return md.H1("Not found") + ui.Empty("No author given.") } return md.H1("Notes by "+ui.AddrOf(addr)) + notesTable(byAuthor.Lookup(addr), "This address has posted nothing.") } // notesTable renders a set of ids as rows, skipping any id the store no longer // holds. // // Skipping rather than panicking: an index and a store are two containers, and // a reader should see the records that exist rather than a page that aborts. // Retract keeps the two in step, so a miss here means a bug worth surviving, // not worth hiding. func notesTable(hits []store.ID, empty string) string { t := ui.NewTable("#", "author", "tag", "note", "height") for _, id := range hits { v, ok := notes.Get(id) if !ok { continue } n := v.(*note) t.Row( id.String(), ui.Addr(n.Author), md.InlineCode(n.Tag), ui.Cell(n.Body), strconv.FormatInt(n.Height, 10), ) } return t.OrEmpty(empty) } // pager renders the previous/next links, or nothing when there is one page. func pager(page, pages int) string { if pages < 2 { return "" } out := "" if page > 1 { out += md.Link("previous", realmURL+":?page="+strconv.Itoa(page-1)) + " " } out += "page " + strconv.Itoa(page) + " of " + strconv.Itoa(pages) if page < pages { out += " " + md.Link("next", realmURL+":?page="+strconv.Itoa(page+1)) } return out }
#3AddPackagegno.land/r/moul/x/plan9/dev/v111 arguments
Attached funds
8000000ugnot

Arguments · 11

  1. #1dev
  2. #2README.md
  3. #3# `gno.land/r/moul/x/plan9/dev/v1` **The chain as a Plan 9 device tree.** Everything a realm normally reaches through an import of `chain/runtime` is published here as a file instead, so it can be read, listed, bound and unioned like anything else. ``` cat /dev/sysname the chain id cat /dev/height the block height cat /dev/session what a delegated key is allowed to touch ``` | device | contents | |---|---| | `caller` | pkgpath of the realm that crossed into this read | | `domain` | the chain domain | | `drivers` | the table itself | | `height` | current block height | | `null` | always empty | | `random` | sha256 of chain id and height, hex; block-deterministic, **not** unpredictable | | `session` | the calling key's session scope, one `AllowPath` per line | | `sysname` | the chain id | | `time` | block time, RFC3339 | | `user` | the origin caller's address | | `zero` | endless NUL bytes; reads exactly what you ask for | A device is code, not storage: reading `/dev/height` runs a function, so it is never stale. ## The cross-realm mount This realm posts its tree to [`r/moul/x/plan9/ns`](../../../../../r/moul/x/plan9/ns/v1)'s `/srv` at deploy time, so any account can bind it into their own namespace and nobody has to import this realm to use it: ``` bind /srv/dev /dev ``` That is the mechanism the whole experiment turns on: an interface value published by one realm, stored by another, and called back later from a read-only `Render`. Gno has no dynamic dispatch by path, so a mount cannot be a client pulling a server by name; `/srv` is not a convenience here, it is the only available mechanism, which is a pleasing accident. The tree is read-only, like every [`synfs`](../../../../../p/moul/x/plan9/synfs/v0) tree: the files have no `Write`, so grafting this into a stranger's namespace cannot be turned into a write against this realm. ## Why `/dev/session` is the interesting one A gno.land account session is a delegated key scoped to a list of path prefixes. That is Plan 9's "the namespace *is* the capability set", rediscovered thirty-four years later, minus the ability to look at it as a tree. This file prints it. Design and analysis: [moul/gno-contracts#136](https://github.com/moul/gno-contracts/issues/136). --- **Not affiliated with Plan 9.** Plan 9 from Bell Labs is the work of the Computing Science Research Center at Bell Labs; the name and the marks are theirs, and the copyright is held by the [Plan 9 Foundation](https://p9f.org). This realm borrows the vocabulary and none of the code: it is an independent homage, asking what that ecosystem's spirit looks like on a chain. Full attribution: [NOTICE](../../../../../NOTICE.md). <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **On mainnet:** [![deployment status](https://gnoscope.com/_badges/shield/status/r/moul/x/plan9/dev/v1?network=mainnet)](https://gnoscope.com/realm/r/moul/x/plan9/dev/v1) [![transactions](https://gnoscope.com/_badges/shield/txs/r/moul/x/plan9/dev/v1?network=mainnet)](https://gnoscope.com/realm/r/moul/x/plan9/dev/v1) [![unique callers](https://gnoscope.com/_badges/shield/users/r/moul/x/plan9/dev/v1?network=mainnet)](https://gnoscope.com/realm/r/moul/x/plan9/dev/v1) [![deployed revision](https://gnoscope.com/_badges/shield/version/r/moul/x/plan9/dev/v1?network=mainnet)](https://gnoscope.com/realm/r/moul/x/plan9/dev/v1) **Dependency graph:** ![gno.land/r/moul/x/plan9/dev/v1 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/x/plan9/dev/v1/deps.png) > 🧪 **Highly experimental — potentially vibe-coded.** Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
  4. #4dev.gno
  5. #5// Package dev is the chain as a Plan 9 device tree. // // In Plan 9 a device is not storage, it is code behind a name: reading // /dev/time runs a function. Everything a gno realm normally reaches through // an import of chain/runtime is published here as a file instead, so it can be // read, listed, bound and unioned like anything else: // // cat /dev/sysname the chain id // cat /dev/height the block height // cat /dev/session what a delegated key is allowed to touch // // The tree is posted to gno.land/r/moul/x/plan9/ns's /srv at deploy time, so // any account can bind it into their own namespace, and nobody has to import // this realm to use it. That is the cross-realm mount the whole experiment // turns on: an interface value published by one realm, stored by another, and // called later from a read-only Render. // // It is READ-ONLY, like every synthetic tree: the files have no Write, so // grafting this into a stranger's namespace cannot be turned into a write // against this realm. // // /dev/session is the one to look at. A gno.land account session is a // delegated key scoped to a list of path prefixes, which is Plan 9's "the // namespace IS the capability" rediscovered thirty-four years later. Printing // it as a file makes that visible. // // NOTICE. Plan 9 from Bell Labs is the work of the Computing Science Research // Center at Bell Labs; the name and the marks are theirs, and the copyright is // held by the Plan 9 Foundation (https://p9f.org). This realm is not // affiliated with, endorsed by, or sponsored by them, and contains no Plan 9 // code: it borrows the vocabulary so that the design reads without a glossary, // and it is an homage, asking what that ecosystem's spirit looks like on a // chain. Full attribution: NOTICE.md at the root of moul/gno-contracts. package dev import ( "chain/runtime" "chain/runtime/unsafe" "crypto/sha256" "encoding/hex" "strconv" "strings" "time" "gno.land/p/moul/kit/ui/v0" "gno.land/p/moul/md/v0" ninep "gno.land/p/moul/x/plan9/ninep/v0" synfs "gno.land/p/moul/x/plan9/synfs/v0" nsrealm "gno.land/r/moul/x/plan9/ns/v1" ) var tree *synfs.Tree // docs describes each device, for Render and for /dev/drivers. var docs = [][2]string{ {"caller", "pkgpath of the realm that crossed into this read"}, {"domain", "the chain domain"}, {"drivers", "this table"}, {"height", "current block height"}, {"null", "always empty; the bit bucket"}, {"random", "sha256 of chain id and height, hex; block-deterministic, NOT unpredictable"}, {"session", "the calling key's session scope, one AllowPath per line"}, {"sysname", "the chain id"}, {"time", "block time, RFC3339"}, {"user", "the origin caller's address"}, {"zero", "endless NUL bytes; reads exactly what you ask for"}, } func init(cur realm) { tree = build() nsrealm.Post(cross(cur), "dev", tree.Root()) } func build() *synfs.Tree { t := synfs.New("dev", "sys", func() int64 { return runtime.ChainHeight() }) r := t.Root() r.Add("sysname", func() string { return runtime.ChainID() }) r.Add("domain", func() string { return runtime.ChainDomain() }) r.Add("height", func() string { return strconv.FormatInt(runtime.ChainHeight(), 10) }) r.Add("time", func() string { return time.Now().Format(time.RFC3339) }) r.Add("user", func() string { return unsafe.OriginCaller().String() }) r.Add("caller", func() string { return unsafe.PreviousRealm().PkgPath() }) r.Add("null", func() string { return "" }) r.Add("random", func() string { sum := sha256.Sum256([]byte(runtime.ChainID() + ":" + strconv.FormatInt(runtime.ChainHeight(), 10))) return hex.EncodeToString(sum[:]) }) r.Add("session", session) r.Add("drivers", drivers) // /dev/zero has no end, so it serves the read window itself rather than // materialising a value. An unbounded read returns nothing, which is what // stops `cat /dev/zero` from being a denial of service. r.AddRange("zero", 0444, func(off, count int64) (string, error) { if count <= 0 { return "", nil } if count > 4096 { count = 4096 } return strings.Repeat("\x00", int(count)), nil }) return t } // session prints the calling key's authority. A plain key has none, which is // itself worth saying out loud. func session() string { pubKeyAddr, expiresAt, allowPaths, isSession := runtime.GetSessionInfo() if !isSession { var b strings.Builder b.WriteString("session no\n") b.WriteString("scope full\n") return b.String() } var b strings.Builder b.WriteString("session yes\n") b.WriteString("key " + pubKeyAddr.String() + "\n") b.WriteString("expires " + strconv.FormatInt(expiresAt, 10) + "\n") for _, p := range allowPaths { b.WriteString("allow " + p + "\n") } return b.String() } func drivers() string { var b strings.Builder for _, d := range docs { b.WriteString(d[0] + "\t" + d[1] + "\n") } return b.String() } // Root returns the device tree, for a realm that would rather import it than // bind it. Reading it is safe from anywhere; it has no mutating method. func Root() ninep.File { return tree.Root() } // Render lists the devices, or reads one. // // Render("") the table of devices // Render("cat/height") one device's contents func Render(path string) string { parts := strings.Split(strings.Trim(path, "/"), "/") if len(parts) >= 2 && parts[0] == "cat" { name := parts[1] f, err := tree.Root().Walk(name) if err != nil { return "# /dev/" + ui.Inline(name) + "\n\n" + md.CodeBlock(err.Error()) } data, err := ninep.ReadAll(f) if err != nil { return "# /dev/" + ui.Inline(name) + "\n\n" + md.CodeBlock(err.Error()) } return "# /dev/" + ui.Inline(name) + "\n\n" + md.CodeBlock(strings.TrimSuffix(data, "\n")) + "\n[all devices](/r/moul/x/plan9/dev/v1)\n" } return renderIndex() } func renderIndex() string { var b strings.Builder b.WriteString("# /dev\n\n") b.WriteString("The chain as a Plan 9 device tree. Each file is a function: reading it ") b.WriteString("runs code, so `/dev/height` is never stale.\n\n") b.WriteString("Posted to [`r/moul/x/plan9/ns`](/r/moul/x/plan9/ns/v1)'s `/srv` at deploy ") b.WriteString("time, so no realm has to import this one to use it:\n\n") b.WriteString("```\nbind /srv/dev /dev\n```\n\n") b.WriteString("| device | contents |\n|---|---|\n") for _, d := range docs { b.WriteString("| [`" + d[0] + "`](/r/moul/x/plan9/dev/v1:cat/" + d[0] + ") | " + d[1] + " |\n") } b.WriteString("\nDesign and analysis: ") b.WriteString("[moul/gno-contracts#136](https://github.com/moul/gno-contracts/issues/136).\n") b.WriteString("\n_Not affiliated with Plan 9. Plan 9 from Bell Labs is the ") b.WriteString("work of the Computing Science Research Center at Bell Labs; the name ") b.WriteString("and the marks are theirs, and the copyright is held by the ") b.WriteString("[Plan 9 Foundation](https://p9f.org). This realm borrows the ") b.WriteString("vocabulary and none of the code: it is an homage, asking what that ") b.WriteString("ecosystem's spirit looks like on a chain._\n") return b.String() }
  6. #6dev_test.gno
  7. #7package dev import ( "chain/runtime" "strconv" "strings" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ninep "gno.land/p/moul/x/plan9/ninep/v0" nsrealm "gno.land/r/moul/x/plan9/ns/v1" ) func read(t *testing.T, name string) string { t.Helper() f, err := tree.Root().Walk(name) if err != nil { t.Fatalf("walk %s: %v", name, err) } data, err := ninep.ReadAll(f) if err != nil { t.Fatalf("read %s: %v", name, err) } return data } func TestDevicesFollowTheChain(t *testing.T) { if got, want := read(t, "sysname"), runtime.ChainID(); got != want { t.Errorf("sysname: got %q, want %q", got, want) } if got, want := read(t, "domain"), runtime.ChainDomain(); got != want { t.Errorf("domain: got %q, want %q", got, want) } if got, want := read(t, "height"), strconv.FormatInt(runtime.ChainHeight(), 10); got != want { t.Errorf("height: got %q, want %q", got, want) } // A device is code, not storage: advance the chain and the file follows. before := read(t, "height") testing.SkipHeights(5) after := read(t, "height") if before == after { t.Errorf("height did not move: %q", after) } b, _ := strconv.ParseInt(before, 10, 64) a, _ := strconv.ParseInt(after, 10, 64) if a-b != 5 { t.Errorf("height moved by %d, want 5", a-b) } } func TestRandomIsBlockDeterministic(t *testing.T) { a := read(t, "random") if len(a) != 64 { t.Errorf("a sha256 in hex is 64 characters, got %d", len(a)) } if a != read(t, "random") { t.Error("two reads in the same block must agree, or Render is a consensus bug") } testing.SkipHeights(1) if a == read(t, "random") { t.Error("the value should change with the block") } } func TestNullAndZero(t *testing.T) { if got := read(t, "null"); got != "" { t.Errorf("null: %q", got) } f, _ := tree.Root().Walk("zero") got, _ := f.Read(0, 4) if got != "\x00\x00\x00\x00" { t.Errorf("zero: %q", got) } // An endless file must not answer an unbounded read with an endless value. if got, _ := f.Read(0, -1); got != "" { t.Errorf("unbounded read of /dev/zero: %d bytes", len(got)) } if got, _ := f.Read(0, 1<<20); len(got) != 4096 { t.Errorf("a huge read should cap at 4096, got %d", len(got)) } } func TestSessionReportsAPlainKey(t *testing.T) { got := read(t, "session") if got != "session no\nscope full\n" { t.Errorf("a plain key has no session scope: %q", got) } } func TestDriversListsEveryDevice(t *testing.T) { got := read(t, "drivers") ents, err := tree.Root().ReadDir() if err != nil { t.Fatalf("readdir: %v", err) } for _, e := range ents { if !strings.Contains(got, e.Name+"\t") { t.Errorf("/dev/drivers does not document %q", e.Name) } } if len(ents) != len(docs) { t.Errorf("%d devices but %d documented", len(ents), len(docs)) } } func TestTreeIsReadOnly(t *testing.T) { var f ninep.File = tree.Root() if _, ok := f.(ninep.Mutable); ok { t.Error("a device tree handed to other realms must not be Mutable") } } // TestPostedAtDeployTime is the cross-realm claim the whole suite rests on: // this realm pushed an interface value into another realm at init, and that // realm can call it back later from a read. func TestPostedAtDeployTime(t *testing.T) { found := false for _, s := range nsrealm.Services() { if s == "dev" { found = true } } if !found { t.Fatalf("dev is not posted in /srv: %v", nsrealm.Services()) } } func TestAFreshNamespaceHasDevBound(cur realm, t *testing.T) { user := testutils.TestAddress("glenda") testing.SetRealm(testing.NewUserRealm(user)) nsrealm.Reset(cross(cur)) // Touch the namespace so it is built with /dev already bound. nsrealm.Exec(cross(cur), "echo hi > /tmp/hi") out, err := nsrealm.Run(user.String(), "ls /dev") if err != nil { t.Fatalf("ls /dev: %v", err) } for _, d := range docs { if !strings.Contains(out, d[0]) { t.Errorf("ls /dev is missing %q: %q", d[0], out) } } got, err := nsrealm.Run(user.String(), "cat /dev/sysname") if err != nil { t.Fatalf("cat: %v", err) } if got != runtime.ChainID() { t.Errorf("reading this realm's device through another realm's namespace: %q", got) } // And the mount table says where it came from. if ns := nsrealm.Namespace(user.String()); !strings.Contains(ns, "bind /srv/dev /dev") { t.Errorf("mount table: %q", ns) } } // The device name comes from the URL: escaped in the heading. func TestDeviceNameIsEscaped(t *testing.T) { // No slash in the payload: Render splits the path on "/". out := Render("cat/x` [claim](evil.example) `") uassert.False(t, strings.Contains(out, "[claim](evil.example)"), out) }
  8. #8gnomod.toml
  9. #9module = "gno.land/r/moul/x/plan9/dev/v1" gno = "0.9" # public: posts its own tree into r/moul/x/plan9/ns, and a private realm may # not persist an object it owns into another realm
  10. #10render_example_test.gno
  11. #11package dev // ExampleRender pins the device index. It carries no chain value, so it does // not move with the block height the way the devices themselves do. func ExampleRender() { print(Render("")) // Output: // # /dev // // The chain as a Plan 9 device tree. Each file is a function: reading it runs code, so `/dev/height` is never stale. // // Posted to [`r/moul/x/plan9/ns`](/r/moul/x/plan9/ns/v1)'s `/srv` at deploy time, so no realm has to import this one to use it: // // ``` // bind /srv/dev /dev // ``` // // | device | contents | // |---|---| // | [`caller`](/r/moul/x/plan9/dev/v1:cat/caller) | pkgpath of the realm that crossed into this read | // | [`domain`](/r/moul/x/plan9/dev/v1:cat/domain) | the chain domain | // | [`drivers`](/r/moul/x/plan9/dev/v1:cat/drivers) | this table | // | [`height`](/r/moul/x/plan9/dev/v1:cat/height) | current block height | // | [`null`](/r/moul/x/plan9/dev/v1:cat/null) | always empty; the bit bucket | // | [`random`](/r/moul/x/plan9/dev/v1:cat/random) | sha256 of chain id and height, hex; block-deterministic, NOT unpredictable | // | [`session`](/r/moul/x/plan9/dev/v1:cat/session) | the calling key's session scope, one AllowPath per line | // | [`sysname`](/r/moul/x/plan9/dev/v1:cat/sysname) | the chain id | // | [`time`](/r/moul/x/plan9/dev/v1:cat/time) | block time, RFC3339 | // | [`user`](/r/moul/x/plan9/dev/v1:cat/user) | the origin caller's address | // | [`zero`](/r/moul/x/plan9/dev/v1:cat/zero) | endless NUL bytes; reads exactly what you ask for | // // Design and analysis: [moul/gno-contracts#136](https://github.com/moul/gno-contracts/issues/136). // // _Not affiliated with Plan 9. Plan 9 from Bell Labs is the work of the Computing Science Research Center at Bell Labs; the name and the marks are theirs, and the copyright is held by the [Plan 9 Foundation](https://p9f.org). This realm borrows the vocabulary and none of the code: it is an homage, asking what that ecosystem's spirit looks like on a chain._ } // ExampleRender_cat pins one device read. /dev/drivers is the one device whose // contents are fixed, which makes it the only one an example can assert; the // chain-derived ones are checked against chain/runtime in dev_test.gno. func ExampleRender_cat() { print(Render("cat/drivers")) // Output: // # /dev/drivers // // ``` // caller pkgpath of the realm that crossed into this read // domain the chain domain // drivers this table // height current block height // null always empty; the bit bucket // random sha256 of chain id and height, hex; block-deterministic, NOT unpredictable // session the calling key's session scope, one AllowPath per line // sysname the chain id // time block time, RFC3339 // user the origin caller's address // zero endless NUL bytes; reads exactly what you ask for // ``` // // [all devices](/r/moul/x/plan9/dev/v1) }
#4AddPackagegno.land/r/moul/x/social/crews/v011 arguments
Attached funds
17000000ugnot

Arguments · 11

  1. #1crews
  2. #2README.md
  3. #3# crews A crew is three to fifteen people with a shared pot, a way to decide, and a way to leave with their share. One transaction to start one. The chain wiring for [`p/moul/x/social/crew`](https://github.com/moul/gno-contracts/tree/main/p/moul/x/social/crew), which holds the share math and the proposal state. This realm reads the caller, reads the coins attached to the call, asks the engine, and renders the result. ## The API | call | | |---|---| | `Create(name)` | payable. You become the crew's first member, your shares bought out of what you sent at `InitialPricePerShare`. | | `Join(crewID)` | payable. Mints shares at what a share is worth **now**: `amount * totalShares / treasury`, rounded down. | | `Fund(crewID)` | payable. Adds to the treasury and mints nothing, so every existing share is worth more. | | `Propose(crewID, text)` | any member. Open for `VoteBlocks` blocks. | | `Vote(proposalID, yes)` | weighted by the shares you hold right now, changeable while it is open. | | `Close(proposalID)` | anyone, after the deadline. Records passed or failed by share weight. | | `Ragequit(crewID)` | burn every share you hold, be credited your pro-rata of the treasury, leave. | | `Withdraw()` | collect what you were credited. | Reads: `Get`, `Count`, `SharesOf`, `MemberCount`, `Treasury`, `ValuePerShare`, `ProposalOf`, `Tally`, `CreditOf`, `TotalOwed`. `Render` serves three views: the index, `:crew/<id>`, and `:proposal/<id>`. ## Shares are the token, and that is the answer The shares **are** the token. They are the vote weight and the claim on the treasury at once, minted by joining and burned by leaving, so nothing about them is decorative and nobody has to be persuaded they are worth something. v0 keeps them as an internal ledger rather than one GRC20 per crew, because a GRC20 per crew means a realm per crew. A transferable share belongs in [`p/moul/x/social/coin`](https://github.com/moul/gno-contracts/tree/main/p/moul/x/social/coin), the sibling that refuses to exist until its mint rule, sink and buyer are declared. This realm does not import it. ## The custody caveat, which is real **One realm address holds every crew's treasury**, with per-crew accounting inside it. The chain sees one balance; which crew owns which part of it is a number in this realm's state. So a bug in that accounting is a bug **across** crews, not inside one. An arithmetic error that over-credits one crew's ragequit pays it out of another crew's money, and nothing at the bank layer would refuse that transfer: as far as the chain is concerned it is all the same address paying itself out. The fix is the instance-per-realm pattern (`EFFECTIVE_GNO.md` section 1.4, in this repository): one realm per crew, each holding its own coins at its own address, with `p/moul/x/social/crew` as the shared engine. That is v1. It is not v0 because deploying a realm per crew is a publish per crew, which is the opposite of "create it in one transaction", and the one transaction is the whole product claim. ## Payouts are pulled, never pushed A ragequit credits an internal ledger and the leaver calls `Withdraw` themselves. One address that cannot be paid cannot wedge anybody else, and the credit is zeroed before the coins move, so a reentrant call finds nothing left to take. ## What a proposal does not do Nothing. A v0 proposal is advisory text: passing one records that the crew agreed by share weight at a point in time, and moves no coins, changes no membership and binds no code. Executing a payout is the next step, and it is what would turn this into a treasury contract rather than a notice board. ## Why this realm is private `private = true`, so it can be redeployed at this path by its creator, and no other realm may import it. The trade is the usual one: a redeploy wipes every package-level variable, so every crew, every share and every credit would go with it while the coins stayed at the address. Nothing imports this realm and nothing is meant to, so the redeploy door is worth more than the import one while the design is still moving. The v1 above is the move that closes it. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **On mainnet:** [![deployment status](https://gnoscope.com/_badges/shield/status/r/moul/x/social/crews/v0?network=mainnet)](https://gnoscope.com/realm/r/moul/x/social/crews/v0) [![transactions](https://gnoscope.com/_badges/shield/txs/r/moul/x/social/crews/v0?network=mainnet)](https://gnoscope.com/realm/r/moul/x/social/crews/v0) [![unique callers](https://gnoscope.com/_badges/shield/users/r/moul/x/social/crews/v0?network=mainnet)](https://gnoscope.com/realm/r/moul/x/social/crews/v0) [![deployed revision](https://gnoscope.com/_badges/shield/version/r/moul/x/social/crews/v0?network=mainnet)](https://gnoscope.com/realm/r/moul/x/social/crews/v0) **Dependency graph:** ![gno.land/r/moul/x/social/crews/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/x/social/crews/v0/deps.png) > 🧪 **Highly experimental — potentially vibe-coded.** Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
  4. #4crews.gno
  5. #5// Package crews is small-group coordination you can create in one // transaction: three to fifteen people with a shared pot, a way to decide, and // a way to leave with their share. // // It is the chain wiring for [gno.land/p/moul/x/social/crew], which holds the // share math and the proposal state. This realm reads the caller, reads the // coins attached to the call, asks the engine, and renders the result. // // Create pay in, and you are the crew's first member // Join pay in at what a share is worth NOW, not at what it cost them // Fund pay in and mint nothing, so every existing share is worth more // Propose any member opens an advisory question // Vote weighted by the shares you hold, changeable while it is open // Close anyone, once the deadline has gone by // Ragequit burn your shares, be credited your slice, leave // Withdraw collect what you were credited // // # The shares are the token // // There is no second asset to issue. A share is the vote weight and the claim // on the treasury at the same moment, minted by paying in and burned by // leaving, so nothing about it is decorative and nobody has to be persuaded it // is worth something. v0 keeps the shares as an internal ledger rather than one // GRC20 per crew, because a GRC20 per crew means a realm per crew. A // transferable share belongs in [gno.land/p/moul/x/social/coin], the sibling // that refuses to exist until its mint rule, sink and buyer are declared; this // realm does not import it. // // # The custody caveat, which is real // // ONE realm address holds EVERY crew's treasury, with per-crew accounting // inside it. The chain sees one balance; which crew owns which part of it is a // number in this realm's state. So a bug in the accounting is a bug across // crews, not inside one: an arithmetic error that over-credits one crew's // ragequit pays it out of another crew's money, and nothing at the bank layer // would refuse that transfer. // // The fix is the instance-per-realm pattern (EFFECTIVE_GNO.md section 1.4): one // realm per crew, each holding its own coins at its own address, with this // package as the shared engine. That is v1. It is not v0 because deploying a // realm per crew is a publish per crew, which is the opposite of "create it in // one transaction", and the whole product claim here is the one transaction. // // # Payouts are pulled // // Nothing is ever pushed. A ragequit credits an internal ledger and the leaver // calls [Withdraw] themselves, so one address that cannot be paid cannot wedge // anybody else, and the credit is zeroed before the coins move. package crews import ( "strconv" "chain" "chain/banker" "chain/runtime" "gno.land/p/moul/kit/store/v0" "gno.land/p/moul/x/envelope/v0" "gno.land/p/moul/x/social/crew/v0" ) // realmPath is this realm's own path, the one its gnomod.toml module line // declares. It is written out rather than read from the runtime, because every // plain read this realm exports reports its CALLER and would build every link // against the wrong realm. const realmPath = "gno.land/r/moul/x/social/crews/v0" // denom is the only coin a crew holds. ugnot and nothing else: a treasury that // can hold several denominations needs a per-denomination share price, which is // a different product. const denom = "ugnot" // IndexCrews is how many crews the index page lists. const IndexCrews = 20 // crews holds every crew, every proposal and the credit ledger. A redeploy // wipes it, which is the trade gnomod.toml's private = true makes: see the // comment there. var crews = crew.New() // Create opens a crew and makes the caller its first member. // // Send ugnot with the call: the founder's shares are bought out of it at // crew.InitialPricePerShare, and the whole amount, remainder included, becomes // the treasury. Returns the new crew's id. func Create(cur realm, name string) int64 { // Both checks have to be here and not behind a helper. cur.IsCurrent() // before cur.Previous() is the realm-token rule; IsUserCall before the // envelope read is the payment one, because the envelope reports what // the SIGNER attached to the transaction and not what reached this // realm. Without it a realm the user called keeps the coins and calls // in here as many times as it likes, every call reading the same send. if !cur.IsCurrent() { panic("spoofed realm: cur is not the live crossing frame") } if !cur.Previous().IsUserCall() { panic("crews: paying in must be a direct user transaction") } who := cur.Previous().Address() amount := envelope.RequireAtLeast(denom, crew.InitialPricePerShare) id, err := crews.Create(name, who, amount, runtime.ChainHeight()) if err != nil { panic(err.Error()) } chain.Emit("Create", "crew", id.String(), "founder", who.String(), "amount", strconv.FormatInt(amount, 10), ) return int64(id) } // Join mints the caller shares at what a share is worth right now and adds what // they sent to the treasury. Returns the shares minted. // // The price is amount * totalShares / treasury, rounded down, so somebody // joining a crew that has tripled its money pays three times what the founders // did. Send at least ValuePerShare ugnot, or there is no whole share to mint. func Join(cur realm, crewID int64) int64 { // The two guards every payable call needs; see Create. if !cur.IsCurrent() { panic("spoofed realm: cur is not the live crossing frame") } if !cur.Previous().IsUserCall() { panic("crews: paying in must be a direct user transaction") } who := cur.Previous().Address() id := store.ID(crewID) c, ok := crews.Get(id) if !ok { panic(crew.ErrNoCrew.Error()) } amount := envelope.RequireAtLeast(denom, c.PricePerShare()) shares, err := crews.Join(id, who, amount) if err != nil { panic(err.Error()) } chain.Emit("Join", "crew", id.String(), "member", who.String(), "amount", strconv.FormatInt(amount, 10), "shares", strconv.FormatInt(shares, 10), ) return shares } // Fund adds what the caller sent to a crew's treasury and mints nothing, so // every existing share is worth more afterwards. // // It is how revenue, a grant or a member topping the pot up arrives. Anyone may // fund any crew, member or not: there is no way to refuse a transfer on this // chain anyway, and pretending otherwise would only move the donation to a bank // send the realm cannot account for. func Fund(cur realm, crewID int64) int64 { // The two guards every payable call needs; see Create. if !cur.IsCurrent() { panic("spoofed realm: cur is not the live crossing frame") } if !cur.Previous().IsUserCall() { panic("crews: paying in must be a direct user transaction") } who := cur.Previous().Address() amount := envelope.RequireAtLeast(denom, 1) if err := crews.Fund(store.ID(crewID), amount); err != nil { panic(err.Error()) } chain.Emit("Fund", "crew", store.ID(crewID).String(), "from", who.String(), "amount", strconv.FormatInt(amount, 10), ) return amount } // Propose opens an advisory question on a crew. Any member may propose, and it // stays open for crew.VoteBlocks blocks. Returns the proposal's id. // // A proposal EXECUTES NOTHING. Passing one records that the crew agreed by // share weight, and moves no coins, changes no membership and binds no code. // Executing a payout is the next step and it is not in v0. func Propose(cur realm, crewID int64, text string) int64 { who := caller(cur) pid, err := crews.Propose(store.ID(crewID), who, text, runtime.ChainHeight()) if err != nil { panic(err.Error()) } chain.Emit("Propose", "crew", store.ID(crewID).String(), "proposal", pid.String(), "author", who.String(), ) return int64(pid) } // Vote records the caller's ballot, weighted by the shares they hold at this // moment. One ballot per member, changeable while the proposal is open. func Vote(cur realm, proposalID int64, yes bool) { who := caller(cur) pid := store.ID(proposalID) if err := crews.Vote(pid, who, yes, runtime.ChainHeight()); err != nil { panic(err.Error()) } chain.Emit("Vote", "proposal", pid.String(), "voter", who.String(), "yes", strconv.FormatBool(yes), ) } // Close records a proposal as passed or failed once its deadline has gone by, // and returns which. Anyone may call it: closing is bookkeeping, not authority. func Close(cur realm, proposalID int64) bool { pid := store.ID(proposalID) passed, err := crews.Close(pid, runtime.ChainHeight()) if err != nil { panic(err.Error()) } chain.Emit("Close", "proposal", pid.String(), "passed", strconv.FormatBool(passed)) return passed } // Ragequit burns every share the caller holds, credits them their pro-rata // slice of the treasury and removes them from the crew. Returns the amount // credited, which [Withdraw] collects. // // Nobody has to agree. That is what makes a share mean something: the exit is // priced by the same number that votes, and a member outvoted on everything can // still leave with what they put in plus their part of what the crew built. func Ragequit(cur realm, crewID int64) int64 { who := caller(cur) id := store.ID(crewID) shares, amount, err := crews.Ragequit(id, who) if err != nil { panic(err.Error()) } chain.Emit("Ragequit", "crew", id.String(), "member", who.String(), "shares", strconv.FormatInt(shares, 10), "amount", strconv.FormatInt(amount, 10), ) return amount } // Withdraw sends the caller everything they have been credited, and returns it. // // The credit is zeroed before the coins move, so a reentrant call finds nothing // left to take. Nothing in this realm ever pushes value: one address that // cannot be paid must not be able to wedge everybody else. func Withdraw(cur realm) int64 { who := caller(cur) amount, err := crews.Withdraw(who) if err != nil { panic(err.Error()) } bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur) bnk.SendCoins(cur.Address(), who, chain.NewCoins(chain.NewCoin(denom, amount))) chain.Emit("Withdraw", "payee", who.String(), "amount", strconv.FormatInt(amount, 10)) return amount } // Get returns a crew's name, the height it was created at, how many members it // has, its total shares and its treasury in ugnot. // // Flat values rather than the crew itself: handing out a pointer to live state // is a mutation handle nothing checks, and a struct is not something a wallet // can decode anyway. func Get(crewID int64) (string, int64, int, int64, int64) { c, ok := crews.Get(store.ID(crewID)) if !ok { return "", 0, 0, 0, 0 } return c.Name, c.CreatedAt, c.MemberCount(), c.TotalShares, c.Treasury } // Count is how many crews exist. func Count() int { return crews.Len() } // SharesOf is how many shares who holds in a crew. func SharesOf(crewID int64, who address) int64 { c, _ := crews.Get(store.ID(crewID)) return c.SharesOf(who) } // MemberCount is how many people are in a crew. func MemberCount(crewID int64) int { c, _ := crews.Get(store.ID(crewID)) return c.MemberCount() } // Treasury is what a crew holds, in ugnot. The coins themselves sit at this // realm's single address: see the package doc on custody. func Treasury(crewID int64) int64 { c, _ := crews.Get(store.ID(crewID)) if c == nil { return 0 } return c.Treasury } // ValuePerShare is what one of a crew's shares is worth in ugnot, rounded down. func ValuePerShare(crewID int64) int64 { c, _ := crews.Get(store.ID(crewID)) return c.ValuePerShare() } // ProposalOf returns a proposal's crew, author, text, deadline and whether it // has been closed and passed. func ProposalOf(proposalID int64) (int64, address, string, int64, bool, bool) { p, ok := crews.Proposal(store.ID(proposalID)) if !ok { return 0, "", "", 0, false, false } return int64(p.CrewID), p.Author, p.Text, p.Deadline, p.Closed, p.Passed } // Tally is a proposal's share-weighted yes and no. func Tally(proposalID int64) (int64, int64) { p, ok := crews.Proposal(store.ID(proposalID)) if !ok { return 0, 0 } return p.Yes, p.No } // CreditOf is what an address can withdraw right now, in ugnot. func CreditOf(who address) int64 { return crews.CreditOf(who) } // TotalOwed is every outstanding credit, which is the part of this realm's // balance that belongs to people who have already left. func TotalOwed() int64 { return crews.TotalOwed() } // caller is the address that called us, checked the one way that is safe. func caller(cur realm) address { if !cur.IsCurrent() { panic("spoofed realm: cur is not the live crossing frame") } return cur.Previous().Address() }
  6. #6crews_test.gno
  7. #7package crews import ( "strconv" "strings" "testing" "chain" "chain/banker" "gno.land/p/moul/x/social/crew/v0" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" "gno.land/p/nt/urequire/v0" ) var realmAddr = chain.PackageAddress(realmPath) var ( alice = testutils.TestAddress("alice") bob = testutils.TestAddress("bob") carol = testutils.TestAddress("carol") ) // tenGNOT is what every founder here puts in: 10,000 shares at the opening // price, so halving and doubling stay exact and the rounding tests can use // deliberately awkward numbers instead. const tenGNOT = int64(10000000) // reset clears realm state. Realm globals live for the whole test binary, so // everything a later test reads starts here. func reset() { crews = crew.New() } // create calls Create the way a real transaction would: the runtime credits // the realm's address, then the envelope reports the send to the crossing // function. The account switch and the cross are in the same frame on purpose, // since testing.SetRealm governs nothing else. func create(cur realm, who address, amount int64, name string) int64 { testing.IssueCoins(realmAddr, chain.Coins{{denom, amount}}) testing.SetOriginSend(chain.Coins{{denom, amount}}) testing.SetRealm(testing.NewUserRealm(who)) return Create(cross(cur), name) } // join is [create] for Join. func join(cur realm, who address, amount, crewID int64) int64 { testing.IssueCoins(realmAddr, chain.Coins{{denom, amount}}) testing.SetOriginSend(chain.Coins{{denom, amount}}) testing.SetRealm(testing.NewUserRealm(who)) return Join(cross(cur), crewID) } // fund is [create] for Fund. func fund(cur realm, who address, amount, crewID int64) int64 { testing.IssueCoins(realmAddr, chain.Coins{{denom, amount}}) testing.SetOriginSend(chain.Coins{{denom, amount}}) testing.SetRealm(testing.NewUserRealm(who)) return Fund(cross(cur), crewID) } func TestCreateMakesTheFounderTheOnlyMember(cur realm, t *testing.T) { reset() id := create(cur, alice, tenGNOT, "validators") name, at, members, shares, treasury := Get(id) uassert.Equal(t, "validators", name) uassert.Equal(t, int64(123), at, "tests start at height 123") uassert.Equal(t, 1, members) uassert.Equal(t, tenGNOT/crew.InitialPricePerShare, shares) uassert.Equal(t, tenGNOT, treasury) uassert.Equal(t, shares, SharesOf(id, alice)) uassert.Equal(t, int64(0), SharesOf(id, bob)) uassert.Equal(t, crew.InitialPricePerShare, ValuePerShare(id)) uassert.Equal(t, 1, Count()) uassert.Equal(t, tenGNOT, Treasury(id)) } // A second joiner at the same per-share value gets the same shares, which is // the baseline the anti-dilution rule is measured against. func TestASecondJoinerAtTheSamePriceGetsTheSameShares(cur realm, t *testing.T) { reset() id := create(cur, alice, tenGNOT, "crew") got := join(cur, bob, tenGNOT, id) uassert.Equal(t, SharesOf(id, alice), got, "same money, same shares") uassert.Equal(t, crew.InitialPricePerShare, ValuePerShare(id), "the price did not move") uassert.Equal(t, 2, MemberCount(id)) uassert.Equal(t, 2*tenGNOT, Treasury(id)) } // A joiner arriving after the treasury grew gets FEWER shares for the same // money: they buy at what a share is worth now, not at what the founders paid. func TestAJoinerAfterTheTreasuryGrewGetsFewer(cur realm, t *testing.T) { reset() id := create(cur, alice, tenGNOT, "crew") fund(cur, carol, tenGNOT, id) // the crew doubled its money urequire.Equal(t, 2*crew.InitialPricePerShare, ValuePerShare(id)) got := join(cur, bob, tenGNOT, id) uassert.Equal(t, SharesOf(id, alice)/2, got, "the same money buys half as much") uassert.Equal(t, 2*crew.InitialPricePerShare, ValuePerShare(id), "and joining moved the value for nobody") uassert.Equal(t, int64(0), SharesOf(id, carol), "funding mints nothing") } // Ragequit credits, Withdraw pays, and the coins actually move. func TestRagequitCreditsAndWithdrawPays(cur realm, t *testing.T) { reset() id := create(cur, alice, tenGNOT, "crew") join(cur, bob, tenGNOT, id) fund(cur, carol, 2*tenGNOT, id) // treasury 4x, still 2x the shares testing.SetRealm(testing.NewUserRealm(bob)) owed := Ragequit(cross(cur), id) uassert.Equal(t, 2*tenGNOT, owed, "half the shares of a treasury worth four") uassert.Equal(t, owed, CreditOf(bob), "credited, never sent") uassert.Equal(t, owed, TotalOwed()) uassert.Equal(t, 1, MemberCount(id)) uassert.Equal(t, int64(0), SharesOf(id, bob)) uassert.Equal(t, 2*tenGNOT, Treasury(id)) before := banker.NewReadonlyBanker().GetCoins(bob).AmountOf(denom) testing.SetRealm(testing.NewUserRealm(bob)) got := Withdraw(cross(cur)) uassert.Equal(t, owed, got) uassert.Equal(t, int64(0), CreditOf(bob), "zeroed before the coins moved") uassert.Equal(t, int64(0), TotalOwed()) after := banker.NewReadonlyBanker().GetCoins(bob).AmountOf(denom) uassert.Equal(t, before+owed, after, "the coins actually arrived") testing.SetRealm(testing.NewUserRealm(bob)) uassert.AbortsContains(t, cur, "nothing to withdraw", func() { Withdraw(cross(cur)) }) } // Everyone ragequitting empties the treasury to the last ugnot, even when no // single division is exact: the dust a leaver rounds away belongs to whoever is // still in, and the last one out holds every share. func TestEveryoneRagequittingEmptiesTheTreasury(cur realm, t *testing.T) { reset() id := create(cur, alice, 1000, "crew") join(cur, bob, 1000, id) join(cur, carol, 1000, id) fund(cur, alice, 2, id) // 3002 ugnot over 3 shares, divides by nothing urequire.Equal(t, int64(3002), Treasury(id)) var paid int64 testing.SetRealm(testing.NewUserRealm(alice)) paid += Ragequit(cross(cur), id) testing.SetRealm(testing.NewUserRealm(bob)) paid += Ragequit(cross(cur), id) testing.SetRealm(testing.NewUserRealm(carol)) paid += Ragequit(cross(cur), id) uassert.Equal(t, int64(3002), paid, "every ugnot left with somebody") uassert.Equal(t, int64(0), Treasury(id), "to the last one") uassert.Equal(t, 0, MemberCount(id)) uassert.Equal(t, int64(3002), TotalOwed()) uassert.Equal(t, int64(1000), CreditOf(alice), "first out, rounded down hardest") uassert.Equal(t, int64(1001), CreditOf(carol), "last out takes the dust") } // A one-member crew ragequitting takes everything, including the ugnot that // bought no whole share: their shares ARE the total, so the division is exact. func TestAOneMemberCrewTakesEverything(cur realm, t *testing.T) { reset() id := create(cur, alice, 5001, "solo") testing.SetRealm(testing.NewUserRealm(alice)) got := Ragequit(cross(cur), id) uassert.Equal(t, int64(5001), got) uassert.Equal(t, int64(0), Treasury(id)) uassert.Equal(t, 0, MemberCount(id)) // An emptied crew is not a dead one: with nothing outstanding there is // no value to price against, so it reopens at the opening price. uassert.Equal(t, int64(0), ValuePerShare(id)) got = join(cur, bob, tenGNOT, id) uassert.Equal(t, tenGNOT/crew.InitialPricePerShare, got) } func TestProposalsAreAdvisoryAndShareWeighted(cur realm, t *testing.T) { reset() id := create(cur, alice, tenGNOT, "crew") // alice: 10000 shares join(cur, bob, tenGNOT/2, id) // bob: 5000 shares testing.SetRealm(testing.NewUserRealm(bob)) pid := Propose(cross(cur), id, "ship v1 before the conference") _, author, text, deadline, closed, passed := ProposalOf(pid) uassert.Equal(t, bob.String(), author.String()) uassert.Equal(t, "ship v1 before the conference", text) uassert.Equal(t, int64(123+crew.VoteBlocks), deadline) uassert.False(t, closed) uassert.False(t, passed) testing.SetRealm(testing.NewUserRealm(bob)) Vote(cross(cur), pid, true) testing.SetRealm(testing.NewUserRealm(alice)) Vote(cross(cur), pid, false) yes, no := Tally(pid) uassert.Equal(t, int64(5000), yes) uassert.Equal(t, int64(10000), no, "a bigger holder outvotes a smaller one") // Changing your mind while it is open replaces the ballot. testing.SetRealm(testing.NewUserRealm(alice)) Vote(cross(cur), pid, true) yes, no = Tally(pid) uassert.Equal(t, int64(15000), yes) uassert.Equal(t, int64(0), no, "the old ballot is removed, not added to") testing.SkipHeights(crew.VoteBlocks) testing.SetRealm(testing.NewUserRealm(carol)) uassert.True(t, Close(cross(cur), pid), "anyone may close it") _, _, _, _, closed, passed = ProposalOf(pid) uassert.True(t, closed) uassert.True(t, passed) // Passing it moved nothing. That is the whole v0 claim. uassert.Equal(t, tenGNOT+tenGNOT/2, Treasury(id)) uassert.Equal(t, int64(0), TotalOwed()) } func TestRefusals(cur realm, t *testing.T) { reset() id := create(cur, alice, tenGNOT, "crew") testing.IssueCoins(realmAddr, chain.Coins{{denom, tenGNOT}}) testing.SetOriginSend(chain.Coins{{denom, tenGNOT}}) testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "already a member", func() { Join(cross(cur), id) }) testing.IssueCoins(realmAddr, chain.Coins{{denom, tenGNOT}}) testing.SetOriginSend(chain.Coins{{denom, tenGNOT}}) testing.SetRealm(testing.NewUserRealm(bob)) uassert.AbortsContains(t, cur, "no such crew", func() { Join(cross(cur), 4242) }) // Create with nothing attached is refused by the envelope, which names // the flag the caller has to fix. testing.SetOriginSend(chain.Coins{}) testing.SetRealm(testing.NewUserRealm(bob)) uassert.AbortsContains(t, cur, "send", func() { Create(cross(cur), "broke") }) testing.SetRealm(testing.NewUserRealm(bob)) uassert.AbortsContains(t, cur, "not a member", func() { Propose(cross(cur), id, "let me in") }) testing.SetRealm(testing.NewUserRealm(bob)) uassert.AbortsContains(t, cur, "not a member", func() { Ragequit(cross(cur), id) }) testing.SetRealm(testing.NewUserRealm(alice)) pid := Propose(cross(cur), id, "a question") testing.SetRealm(testing.NewUserRealm(bob)) uassert.AbortsContains(t, cur, "not a member", func() { Vote(cross(cur), pid, true) }) testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "still open", func() { Close(cross(cur), pid) }) testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "name is empty", func() { create(cur, alice, tenGNOT, " ") }) // A pipe in a name would open a column where the name is a link title // in a table cell, which md.Link's escaper does not cover. testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "name is empty", func() { create(cur, alice, tenGNOT, "a | pipe") }) uassert.Equal(t, 1, Count(), "no refusal created anything") uassert.Equal(t, 1, MemberCount(id)) } // TestRender uses uassert rather than an Example because every view contains // consecutive blank lines, which gno collapses inside an // Output: block. func TestRender(cur realm, t *testing.T) { reset() id := create(cur, alice, tenGNOT, "a crew [with](markdown)") join(cur, bob, tenGNOT, id) testing.SetRealm(testing.NewUserRealm(alice)) pid := Propose(cross(cur), id, "ship v1 | before the conference") testing.SetRealm(testing.NewUserRealm(alice)) Vote(cross(cur), pid, true) index := Render("") uassert.True(t, strings.Contains(index, "# Crews"), index) uassert.True(t, strings.Contains(index, "| 1 | 1 | 0 GNOT |"), "crews, proposals, owed") uassert.True(t, strings.Contains(index, "func=Create"), index) uassert.True(t, strings.Contains(index, "One realm address holds every crew's treasury"), "the index states the custody caveat") uassert.True(t, strings.Contains(index, "[a crew \\[with\\]\\(markdown\\)](/r/moul/x/social/crews/v0:crew/1)"), "md.Link escapes the title exactly once: "+index) sid := strconv.FormatInt(id, 10) page := Render("crew/" + sid) uassert.True(t, strings.Contains(page, "# a crew \\[with\\]\\(markdown\\)"), "the name is escaped where it is shown as prose: "+page) uassert.True(t, strings.Contains(page, "| 2 of 15 | 20000 | 20 GNOT | 0.001 GNOT |"), page) uassert.True(t, strings.Contains(page, "| 10000 | 50% | 10 GNOT |"), "each member holds half the shares and half the money") uassert.True(t, strings.Contains(page, "func=Ragequit"), page) uassert.True(t, strings.Contains(page, "executes nothing"), page) // A pipe in a proposal's text must not open a column in the crew's // proposal table. It goes through ui.Cell for exactly this. uassert.True(t, strings.Contains(page, "ship v1 \\| before the conference"), "the cell is escaped: "+page) uassert.False(t, strings.Contains(page, "| ship v1 | before"), "a raw pipe would open a column: "+page) spid := strconv.FormatInt(pid, 10) prop := Render("proposal/" + spid) uassert.True(t, strings.Contains(prop, "# Proposal #"+spid), prop) uassert.True(t, strings.Contains(prop, "> ship v1 | before the conference"), "a blockquote is prose, where a pipe is inert and must not grow a backslash") uassert.True(t, strings.Contains(prop, "| open until 1123 | 10000 | 0 | 1 |"), prop) uassert.True(t, strings.Contains(prop, "func=Vote"), prop) // Past the deadline the page offers the close instead of the vote. testing.SkipHeights(crew.VoteBlocks) prop = Render("proposal/" + spid) uassert.True(t, strings.Contains(prop, "awaiting a close"), prop) uassert.True(t, strings.Contains(prop, "func=Close"), prop) uassert.False(t, strings.Contains(prop, "func=Vote"), prop) uassert.Equal(t, "# Crews\nNo crew #4242", Render("crew/4242")) uassert.Equal(t, "# Crews\nNot a crew id: nope", Render("crew/nope")) uassert.Equal(t, "# Crews\nNo proposal #4242", Render("proposal/4242")) uassert.Equal(t, "# Crews\nNot a proposal id: nope", Render("proposal/nope")) uassert.True(t, strings.HasPrefix(Render("elsewhere"), "# Not found")) } // An empty realm still renders something a reader can act on. func TestRenderWithNothingInIt(cur realm, t *testing.T) { reset() index := Render("") uassert.True(t, strings.Contains(index, "No crews yet."), index) uassert.True(t, strings.Contains(index, "func=Create"), index) } // Links are rooted at the realm's own path, so they resolve from any page // gnoweb serves them on. func TestLinksAreRootRelative(t *testing.T) { uassert.Equal(t, "/r/moul/x/social/crews/v0", realmURL()) uassert.Equal(t, "/r/moul/x/social/crews/v0:crew/7", crewURL(7)) uassert.Equal(t, "/r/moul/x/social/crews/v0:proposal/7", proposalURL(7)) }
  8. #8gnomod.toml
  9. #9module = "gno.land/r/moul/x/social/crews/v0" gno = "0.9" private = true # private: nothing imports this realm and nothing is meant to. The trade is the # usual one, a redeploy wipes every crew, share and credit while the coins stay # at the address, and it is worth taking while the design is still moving. The # v1 in README.md, one realm per crew, is the move that closes the door.
  10. #10render.gno
  11. #11package crews import ( "strconv" "chain/runtime" "gno.land/p/moul/kit/num/v0" "gno.land/p/moul/kit/store/v0" "gno.land/p/moul/kit/ui/v0" "gno.land/p/moul/md/v0" "gno.land/p/moul/realmpath/v0" "gno.land/p/moul/x/social/crew/v0" "gno.land/p/moul/xmath/v1" ) // Render routes three views: the index, one crew, and one proposal. func Render(path string) string { req := realmpath.Parse(path) switch req.PathPart(0) { case "": return renderIndex() case "crew": return renderCrew(req.PathPart(1)) case "proposal": return renderProposal(req.PathPart(1)) default: return md.H1("Not found") + "No such view: " + ui.Inline(req.Path) } } func renderIndex() string { out := md.H1("Crews") out += "Three to fifteen people with a shared pot, a way to decide, and a way to " + "leave with their share. One transaction to start one.\n\n" t := ui.NewTable("crews", "proposals", "owed to people who left") t.Row( strconv.Itoa(crews.Len()), strconv.Itoa(crews.ProposalCount()), num.GNOTf(crews.TotalOwed()), ) out += t.String() out += md.H2("The crews") out += crewTable(crews.List(IndexCrews)) out += "\n" + ui.Action("Start a crew", "Create", "name", "") + "\n" out += md.H2("How a share works") out += md.BulletList([]string{ "A share is the vote weight and the claim on the treasury at once. There is no second token.", "Joining mints shares at what a share is worth NOW: " + md.InlineCode("amount * totalShares / treasury") + ", rounded down, so a late joiner cannot buy into value the others built.", "Ragequitting burns every share you hold and credits you " + md.InlineCode("shares * treasury / totalShares") + ", rounded down. Nobody has to agree.", "Both round in the crew's favour. The last member out holds every share, " + "so the treasury still empties to the last ugnot.", "A proposal is advisory text. Passing one records agreement and executes nothing.", }) out += md.H2("Custody") out += "One realm address holds every crew's treasury, with per-crew accounting " + "inside. A bug in that accounting is a bug across crews, not inside one. " + "The fix is one realm per crew, and it is v1.\n" return out } func renderCrew(raw string) string { id, ok := store.ParseID(raw) if !ok { return md.H1("Crews") + "Not a crew id: " + ui.Inline(raw) } c, found := crews.Get(id) if !found { return md.H1("Crews") + "No crew #" + id.String() } out := md.H1(ui.Inline(c.Name)) out += md.Italic("crew #"+id.String()+" · opened at block "+ strconv.FormatInt(c.CreatedAt, 10)) + "\n\n" t := ui.NewTable("members", "shares", "treasury", "per share") t.Row( strconv.Itoa(c.MemberCount())+" of "+strconv.Itoa(crew.MaxMembers), strconv.FormatInt(c.TotalShares, 10), num.GNOTf(c.Treasury), num.GNOTf(c.ValuePerShare()), ) out += t.String() out += md.H2("Members") if c.MemberCount() == 0 { out += ui.Empty("Everybody has left. The next member in pays the opening price again.") } else { m := ui.NewTable("member", "shares", "weight", "worth") for _, who := range c.Members() { shares := c.SharesOf(who) m.Row( ui.Addr(who), strconv.FormatInt(shares, 10), num.Pct(xmath.MulDiv(shares, 10000, c.TotalShares)), num.GNOTf(xmath.MulDiv(shares, c.Treasury, c.TotalShares)), ) } out += m.String() } out += md.H2("Proposals") out += proposalTable(c.Proposals()) out += "\nA proposal is advisory text. Passing one records that the crew agreed " + "by share weight, and executes nothing.\n" out += "\n" + ui.Join(" · ", ui.Action("Join", "Join", "crewID", id.String()), ui.Action("Fund", "Fund", "crewID", id.String()), ui.Action("Propose", "Propose", "crewID", id.String(), "text", ""), ui.Action("Ragequit", "Ragequit", "crewID", id.String()), ) + "\n" return out } func renderProposal(raw string) string { pid, ok := store.ParseID(raw) if !ok { return md.H1("Crews") + "Not a proposal id: " + ui.Inline(raw) } p, found := crews.Proposal(pid) if !found { return md.H1("Crews") + "No proposal #" + pid.String() } out := md.H1("Proposal #" + pid.String()) out += md.Blockquote(ui.Inline(p.Text)) + "\n" crewName := "crew #" + p.CrewID.String() if c, ok := crews.Get(p.CrewID); ok { crewName = c.Name } out += md.Italic("by "+ui.Addr(p.Author)+" · in "+ md.Link(ui.ShortN(crewName, crew.ExcerptLen, 0), crewURL(p.CrewID))) + "\n\n" now := runtime.ChainHeight() t := ui.NewTable("state", "yes", "no", "voters") t.Row( state(p, now), strconv.FormatInt(p.Yes, 10), strconv.FormatInt(p.No, 10), strconv.Itoa(p.Voters()), ) out += t.String() if p.Open(now) { out += "\n" + ui.Join(" · ", ui.Action("Vote yes", "Vote", "proposalID", pid.String(), "yes", "true"), ui.Action("Vote no", "Vote", "proposalID", pid.String(), "yes", "false"), ) + "\n" } else if !p.Closed { out += "\n" + ui.Action("Close it", "Close", "proposalID", pid.String()) + "\n" } return out } // crewTable lists crews, newest first. func crewTable(items []crew.Listing) string { t := ui.NewTable("crew", "members", "shares", "treasury") for _, it := range items { t.Row( // ShortN truncates without escaping and md.Link escapes what it // is given: ui.Inline here would escape twice and render the // backslashes. A pipe is what md.Link's escaper does NOT cover, // which is why crew.ValidName refuses one at write time. md.Link(ui.ShortN(it.Crew.Name, crew.ExcerptLen, 0), crewURL(it.ID)), strconv.Itoa(it.Crew.MemberCount()), strconv.FormatInt(it.Crew.TotalShares, 10), num.GNOTf(it.Crew.Treasury), ) } return t.OrEmpty("No crews yet.") } // proposalTable lists a crew's proposals, newest first. // // The link title is the id and not the text, which is the opposite of // crewTable and deliberate: a proposal's text is free prose and may contain a // pipe, md.Link's escaper leaves pipes alone, and a pipe in a link title inside // a table cell opens a column. So the chrome carries the link and the prose // goes through ui.Cell, which is the escaper that knows it is in a table. func proposalTable(ids []store.ID) string { now := runtime.ChainHeight() t := ui.NewTable("#", "proposal", "state", "yes", "no") for i := len(ids) - 1; i >= 0; i-- { p, ok := crews.Proposal(ids[i]) if !ok { continue } t.Row( md.Link("#"+ids[i].String(), proposalURL(ids[i])), // Cut on a rune boundary first, escape once after: escaping // first and cutting after can strand a lone backslash. ui.Cell(ui.ShortN(p.Text, crew.ExcerptLen, 0)), state(p, now), strconv.FormatInt(p.Yes, 10), strconv.FormatInt(p.No, 10), ) } return t.OrEmpty("Nothing has been proposed yet.") } // state is the one word a proposal is in at height now. func state(p *crew.Proposal, now int64) string { switch { case p.Open(now): return "open until " + strconv.FormatInt(p.Deadline, 10) case !p.Closed: return "awaiting a close" case p.Passed: return "passed" default: return "failed" } } // crewURL is the gnoweb path of one crew. func crewURL(id store.ID) string { return realmURL() + ":crew/" + id.String() } // proposalURL is the gnoweb path of one proposal. func proposalURL(id store.ID) string { return realmURL() + ":proposal/" + id.String() } // realmURL is this realm's gnoweb path. The chain domain is the first element // of a package path and a gnoweb path is the rest of it, so this is a prefix // strip and not a hostname the realm has to know. func realmURL() string { for i := 0; i < len(realmPath); i++ { if realmPath[i] == '/' { return realmPath[i:] } } return "/" + realmPath }
#5AddPackagegno.land/r/moul/x/social/curated/v011 arguments
Attached funds
17000000ugnot

Arguments · 11

  1. #1curated
  2. #2README.md
  3. #3# r/moul/x/social/curated A curated list with skin in the game. Anyone lists an entry by locking a deposit, anyone challenges an entry by matching that deposit with a bond, and after a voting window the loser's money goes to the winner. The deposit does not make an entry good. It makes a bad one expensive to leave standing, which is the only reason a list anybody can write to is worth reading at all. The engine is [`p/moul/x/social/curated/v0`](../../../../../p/moul/x/social/curated), a pure package that returns errors and moves no coins. This realm is the chain wiring: the envelope, the banker, the events and `Render`. ## The API | call | pays | does | |---|---|---| | `Apply(key, url, description)` | exactly 1 GNOT | lists the entry immediately | | `Challenge(key)` | exactly that entry's deposit | opens a vote for 1000 blocks | | `Vote(key, keep)` | nothing | one address, one vote, while the window is open | | `Resolve(key)` | nothing | anyone, after the window: pays the winner | | `Unlist(key)` | nothing | the owner takes their own entry down, if unchallenged | | `Withdraw()` | nothing | collects everything credited to the caller | Reads: `Get`, `Count`, `Listed`, `IsListed`, `ChallengeOf`, `CreditOf`. A key is a slug: lowercase ASCII letters, digits, `-`, `_` and `.`, starting alphanumeric, at most 64 bytes. A key whose entry was removed is free to apply for again: a challenge that wins removes an entry, it does not burn the name. A majority of `keep` votes keeps the entry and credits its owner the challenger's bond. Otherwise the entry is removed and the challenger is credited the bond plus the deposit. **A tie keeps the entry**, including the tie of nobody voting at all: the incumbent paid first and is already at risk, so the burden is on the challenger to produce a reason. If ties went the other way, a challenge that convinced nobody would still win and listing anything would be pointless. ## The trap it avoids **Nothing is ever sent to you.** Every payout is a credit in an internal ledger, and the payee calls `Withdraw()` for their own. A realm that looped over winners and sent to each one would fail entirely when one of them could not be paid, and would hand a griefer a denial of service for the price of one entry. `Withdraw` zeroes the credit before the coins move, so a reentrant call finds nothing left to take. The consequence is an invariant worth knowing: every ugnot at this realm's address is either backing a live entry or an open challenge, or already assigned to somebody. The tests assert exactly that, against the chain balance rather than against the realm's own books. ## Voting is sybil-prone, deliberately and visibly `Vote` is one address, one vote, unweighted, and an address is free. A resolution says "nobody with a stake objected enough", never "this is true". Deciding who counts as a person is a different problem with its own realm behind it, `r/moul/x/social/vouch`, a sibling in this family; until a vote is gated on a vouched identity, the two addresses with money on the outcome are the only ones whose vote means anything. Which is the second thing: **voters are paid nothing in v0**. Voting costs gas and returns nothing, so there is no reason for a disinterested address to show up at all. A share of the loser's stake for the winning side is the standard answer, and it is the first thing this realm should grow. After that, an application period, so a bad entry is not visible before anybody can object to it. ## Why the bonds are GNOT and not a token of this list's own A bond has to be denominated in something the challenger already holds. A list that minted its own token and demanded it as the bond would be asking a newcomer to acquire a token whose only use is challenging entries on a list nobody reads yet, which is the chicken-and-egg problem in its purest form: the token is worth something once the list is worth gaming, and the list cannot become worth gaming until challenges work. So v0 bonds are native GNOT, which every account on the chain already has. The answer becomes yes under one condition: **the list is valuable enough that being on it is contested, and the challenge flow is busy enough that a bond denominated in a list token would have a real market price.** Concretely, a steady stream of challenges from addresses that are not the two parties, and a reason to hold the token between challenges. At that point the token earns its own job, and it can pay the voters the paragraph above says go unpaid, which is the sink a bond alone does not provide. The slot it would drop into is `p/moul/x/social/coin/v0`, a GRC20 in this family that refuses to exist until its mint rule, its sink and its buyer are all declared. This realm deliberately does not import it: the three answers are not available yet, and a token issued before they are is a token with no reason to be held. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **On mainnet:** [![deployment status](https://gnoscope.com/_badges/shield/status/r/moul/x/social/curated/v0?network=mainnet)](https://gnoscope.com/realm/r/moul/x/social/curated/v0) [![transactions](https://gnoscope.com/_badges/shield/txs/r/moul/x/social/curated/v0?network=mainnet)](https://gnoscope.com/realm/r/moul/x/social/curated/v0) [![unique callers](https://gnoscope.com/_badges/shield/users/r/moul/x/social/curated/v0?network=mainnet)](https://gnoscope.com/realm/r/moul/x/social/curated/v0) [![deployed revision](https://gnoscope.com/_badges/shield/version/r/moul/x/social/curated/v0?network=mainnet)](https://gnoscope.com/realm/r/moul/x/social/curated/v0) **Dependency graph:** ![gno.land/r/moul/x/social/curated/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/x/social/curated/v0/deps.png) > 🧪 **Highly experimental — potentially vibe-coded.** Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
  4. #4curated.gno
  5. #5// Package curated is a list anyone can get onto by locking a deposit, and // anyone can try to get somebody else off by matching that deposit with a bond. // The loser of the challenge pays the winner. // // It is the registry member of a family of small social apps: a list is the // thing every other one of them eventually needs, and a list is only worth // reading if being on it cost something. // // Apply(key, url, description) -send 1000000ugnot list it, immediately // Challenge(key) -send <the deposit> object to it // Vote(key, keep) while the window is open // Resolve(key) anyone, after it closes // Unlist(key) the owner, if unchallenged // Withdraw() collect what you won // // # Money in, money out // // Coins arrive in the envelope of a call and sit at this realm's address. They // never leave by being pushed: a payout is a credit in a ledger and the payee // calls [Withdraw] for their own. A realm that looped over winners and sent to // each one would fail entirely when one of them could not be paid, and would // hand a griefer a cheap denial of service for the price of one entry. // // Every ugnot here is therefore either backing a live entry or an open // challenge, or already assigned to somebody. The realm's own tests assert // exactly that against the chain balance. // // # Voting is sybil-prone, and the bonds are GNOT // // [Vote] is one address, one vote, unweighted, and an address is free. Read a // resolution as "nobody with a stake objected enough", never as a verdict. // Gating a vote on something harder to manufacture is a different problem with // its own realm behind it, r/moul/x/social/vouch, a sibling in this family. // // Bonds are GNOT and not a token of this list's own: see the README on why // that is the only answer available at v0 and what would change it. // // # What v0 does not do, in the order it should be fixed // // 1. Voters are paid nothing. Voting costs gas and returns nothing, so the // only two addresses with a reason to vote are the ones with money on the // outcome. A share of the loser's stake for the winning side is the // standard answer and the first thing to add. // 2. There is no application period: [Apply] lists immediately. // 3. A challenge cannot be withdrawn and a vote cannot be changed. package curated import ( "chain" "chain/banker" "chain/runtime" "strconv" "gno.land/p/moul/x/envelope/v0" cu "gno.land/p/moul/x/social/curated/v0" ) // realmPath is this realm's own path, the one its gnomod.toml module line // declares. Render builds its links from it rather than from // unsafe.CurrentRealm(), which in a plain read reports the CALLER. const realmPath = "gno.land/r/moul/x/social/curated/v0" const ( // Denom is what a deposit and a bond are paid in. Native coins, so the // amounts are real to a challenger before this list is worth anything. Denom = "ugnot" // Deposit is what listing costs, and therefore also what challenging one // of today's entries costs. One GNOT: enough to make a thousand junk // entries a real expense, cheap enough that one honest entry is not a // decision. Deposit = int64(1000000) // ChallengeBlocks is how long a challenge takes to resolve. Long enough // for a reader who is not watching the chain to notice and vote, short // enough that an entry is not held hostage. ChallengeBlocks = int64(1000) ) // list is every entry and the credit ledger behind them. A redeploy would wipe // it while leaving the coins at the address, which is what the note in // gnomod.toml is about. var list = cu.New(Deposit, ChallengeBlocks) // Apply lists an entry under key, in exchange for exactly [Deposit] ugnot. // // The entry is on the list the moment this returns: there is no application // period in v0, and the check on a bad entry is that anybody can challenge it. // The deposit comes back through [Unlist] and [Withdraw] if nobody ever does. // // key is a slug: lowercase ASCII letters, digits, '-', '_' and '.', starting // alphanumeric. A key whose entry was removed is free to apply for again. func Apply(cur realm, key, url, description string) { // Both checks are inlined rather than hidden behind caller(), and in this // order. cur.IsCurrent() before cur.Previous() is the realm-token rule; // IsUserCall before the envelope read is the payment one, because the // envelope reports what the SIGNER attached to the transaction and not // what reached this realm. Without it a realm the user called keeps the // coins and calls in here as often as it likes, every call reading the // same send: that is r/moul/grant Fund, which recorded 5 donations of // 1 GNOT from one 1 GNOT send with nothing in the treasury. if !cur.IsCurrent() { panic("spoofed realm: cur is not the live crossing frame") } if !cur.Previous().IsUserCall() { panic("curated: paying in must be a direct user transaction") } who := cur.Previous().Address() paid := envelope.RequireExactly(Denom, Deposit) if err := list.Apply(key, url, description, who, paid, runtime.ChainHeight()); err != nil { panic(err.Error()) } chain.Emit("Apply", "key", key, "owner", who.String(), "deposit", strconv.FormatInt(paid, 10)) } // Challenge objects to an entry, in exchange for a bond equal to that entry's // own deposit, and opens a vote that closes [ChallengeBlocks] blocks later. // // An owner cannot challenge their own entry: it would cost them nothing and // would make the entry immune to a real challenge for the whole window. func Challenge(cur realm, key string) { // The two guards every payable call needs, inlined; see Apply. if !cur.IsCurrent() { panic("spoofed realm: cur is not the live crossing frame") } if !cur.Previous().IsUserCall() { panic("curated: paying in must be a direct user transaction") } who := cur.Previous().Address() // The bond is read before the envelope is, so somebody who attaches coins // to a challenge of something unchallengeable is told which of the two is // wrong. bond, ok := list.BondFor(key) if !ok { panic("curated: " + key + " is not an entry that can be challenged") } envelope.RequireExactly(Denom, bond) if err := list.Challenge(key, who, bond, runtime.ChainHeight()); err != nil { panic(err.Error()) } c, _ := list.ChallengeOf(key) chain.Emit("Challenge", "key", key, "by", who.String(), "bond", strconv.FormatInt(bond, 10), "deadline", strconv.FormatInt(c.Deadline, 10)) } // Vote takes a side on an open challenge: keep the entry, or remove it. // // One address, one vote, unweighted, and it cannot be changed. An address is // free, so this is cheap to manufacture; see the package doc. func Vote(cur realm, key string, keep bool) { who := caller(cur) if err := list.Vote(key, who, keep, runtime.ChainHeight()); err != nil { panic(err.Error()) } chain.Emit("Vote", "key", key, "by", who.String(), "keep", strconv.FormatBool(keep)) } // Resolve closes a challenge whose window has passed. Anyone may call it, so // neither party can stall the other by sitting still. // // A majority of keep votes keeps the entry and credits its owner the bond. // Otherwise the entry is removed and the challenger is credited the bond plus // the deposit. A tie, including nobody voting at all, keeps the entry: the // incumbent is the one already at risk, so a challenge that convinced nobody // loses, which is what makes being wrong cost something. func Resolve(cur realm, key string) { who := caller(cur) out, err := list.Resolve(key, runtime.ChainHeight()) if err != nil { panic(err.Error()) } chain.Emit("Resolve", "key", key, "kept", strconv.FormatBool(out.Kept), "winner", out.Winner.String(), "amount", strconv.FormatInt(out.Amount, 10), "keep", strconv.FormatInt(out.Keep, 10), "remove", strconv.FormatInt(out.Remove, 10), "by", who.String()) } // Unlist takes the caller's own entry off the list and credits them the // deposit back, which [Withdraw] then pays out. // // It is refused while a challenge is open: an owner who could walk away // mid-challenge would be risking nothing, which is the one thing the deposit // exists to prevent. func Unlist(cur realm, key string) { who := caller(cur) if err := list.Unlist(key, who); err != nil { panic(err.Error()) } chain.Emit("Unlist", "key", key, "owner", who.String()) } // Withdraw pays the caller everything credited to them and returns it. // // This is the only way coins leave the realm. The credit is zeroed before the // transfer, so a reentrant call finds nothing left to take. func Withdraw(cur realm) int64 { who := caller(cur) amount, err := list.Withdraw(who) if err != nil { panic(err.Error()) } bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur) bnk.SendCoins(cur.Address(), who, chain.NewCoins(chain.NewCoin(Denom, amount))) chain.Emit("Withdraw", "to", who.String(), "amount", strconv.FormatInt(amount, 10)) return amount } // Get returns what is known about an entry: its URL, its description, its // owner, the deposit behind it, the height it was listed at, and its state, // which is one of "listed", "challenged" or "removed". // // A key that was never applied for reads as the zero value with an empty // state, which is how a caller tells it from a removed one. func Get(key string) (url, description string, owner address, deposit, at int64, state string) { e, ok := list.Get(key) if !ok { return "", "", "", 0, 0, "" } return e.URL, e.Description, e.Owner, e.Deposit, e.At, e.State.String() } // Count is how many entries are on the list right now. A challenged entry // counts: a challenge is an objection, not a verdict. func Count() int { return list.Count() } // Listed is every key on the list, oldest first. func Listed() []string { entries := list.Listed() out := make([]string, 0, len(entries)) for _, e := range entries { out = append(out, e.Key) } return out } // IsListed reports whether key is on the list right now. func IsListed(key string) bool { return list.IsListed(key) } // ChallengeOf returns the open challenge against key: who opened it, the bond // they put up, the height voting closes at, the two vote counts, and whether // there is one at all. func ChallengeOf(key string) (challenger address, bond, deadline, keep, remove int64, open bool) { c, ok := list.ChallengeOf(key) if !ok { return "", 0, 0, 0, 0, false } return c.Challenger, c.Bond, c.Deadline, c.Keep, c.Remove, true } // CreditOf is what who can collect with [Withdraw] right now. func CreditOf(who address) int64 { return list.CreditOf(who) } // caller is the address that called us, checked the one way that is safe. func caller(cur realm) address { if !cur.IsCurrent() { panic("spoofed realm: cur is not the live crossing frame") } return cur.Previous().Address() }
  6. #6curated_test.gno
  7. #7package curated import ( "strings" "testing" "chain" "chain/banker" "chain/runtime" cu "gno.land/p/moul/x/social/curated/v0" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" "gno.land/p/nt/urequire/v0" ) var realmAddr = chain.PackageAddress(realmPath) var ( alice = testutils.TestAddress("alice") bob = testutils.TestAddress("bob") carol = testutils.TestAddress("carol") dave = testutils.TestAddress("dave") ) // reset clears realm state. Realm globals live for the whole test binary, so // every entry, challenge and credit a later test reads starts here. The BANK // does not carry over the same way, which is why every balance is asserted in // the test that issued it. func reset() { list = cu.New(Deposit, ChallengeBlocks) } // pay credits the realm the way a real transaction would: the runtime deposits // the envelope at the realm's address, then OriginSend reports it to the // crossing function. func pay(amount int64) { testing.IssueCoins(realmAddr, chain.Coins{{Denom, amount}}) testing.SetOriginSend(chain.Coins{{Denom, amount}}) } // apply lists an entry as who. It crosses immediately after SetRealm, which is // what makes the account switch take: a helper that sets the realm and does // not itself cross is silently ignored. func apply(cur realm, who address, key, url, description string) { pay(Deposit) testing.SetRealm(testing.NewUserRealm(who)) Apply(cross(cur), key, url, description) } // challenge objects to an entry as who, bonding what that entry's deposit was. func challenge(cur realm, who address, key string) { _, _, _, bond, _, _ := Get(key) pay(bond) testing.SetRealm(testing.NewUserRealm(who)) Challenge(cross(cur), key) } // balance is what addr holds on chain right now. func balance(addr address) int64 { return banker.NewReadonlyBanker().GetCoins(addr).AmountOf(Denom) } func TestApplyTakesExactlyTheDepositAndListsAtOnce(cur realm, t *testing.T) { reset() // Underpaying and overpaying are both refused: a realm that silently kept // the excess would have invented a fee nobody agreed to. pay(Deposit - 1) testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "costs exactly", func() { Apply(cross(cur), "gnoswap", "https://gnoswap.io", "an AMM") }) pay(Deposit + 1) testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "costs exactly", func() { Apply(cross(cur), "gnoswap", "https://gnoswap.io", "an AMM") }) uassert.Equal(t, 0, Count()) apply(cur, alice, "gnoswap", "https://gnoswap.io", "an AMM on gno.land") url, description, owner, deposit, at, state := Get("gnoswap") uassert.Equal(t, "https://gnoswap.io", url) uassert.Equal(t, "an AMM on gno.land", description) uassert.Equal(t, alice.String(), owner.String()) uassert.Equal(t, Deposit, deposit) uassert.Equal(t, runtime.ChainHeight(), at) uassert.Equal(t, "listed", state) uassert.True(t, IsListed("gnoswap")) uassert.Equal(t, 1, Count()) urequire.Equal(t, 1, len(Listed())) uassert.Equal(t, "gnoswap", Listed()[0]) // A key that was never applied for reads as the empty state. _, _, _, _, _, state = Get("nothing") uassert.Equal(t, "", state) uassert.False(t, IsListed("nothing")) } func TestApplyRefusesAKeyAlreadyOnTheList(cur realm, t *testing.T) { reset() apply(cur, alice, "gnoswap", "https://gnoswap.io", "an AMM") pay(Deposit) testing.SetRealm(testing.NewUserRealm(bob)) uassert.AbortsContains(t, cur, "already on the list", func() { Apply(cross(cur), "gnoswap", "https://mine.io", "mine now") }) pay(Deposit) testing.SetRealm(testing.NewUserRealm(bob)) uassert.AbortsContains(t, cur, "not a key", func() { Apply(cross(cur), "GnoSwap", "https://mine.io", "mine now") }) uassert.Equal(t, 1, Count()) } func TestChallengeMatchesTheDepositAndOpensAVote(cur realm, t *testing.T) { reset() apply(cur, alice, "gnoswap", "https://gnoswap.io", "an AMM") // Not a bond, not an entry: the entry is checked before the money is. pay(Deposit) testing.SetRealm(testing.NewUserRealm(bob)) uassert.AbortsContains(t, cur, "is not an entry that can be challenged", func() { Challenge(cross(cur), "nothing") }) // An owner shielding their own entry would cost them nothing. pay(Deposit) testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "cannot challenge their own entry", func() { Challenge(cross(cur), "gnoswap") }) opened := runtime.ChainHeight() challenge(cur, bob, "gnoswap") challenger, bond, deadline, keep, remove, open := ChallengeOf("gnoswap") urequire.True(t, open, "the challenge is readable") uassert.Equal(t, bob.String(), challenger.String()) uassert.Equal(t, Deposit, bond) uassert.Equal(t, opened+ChallengeBlocks, deadline) uassert.Equal(t, int64(0), keep) uassert.Equal(t, int64(0), remove) uassert.True(t, IsListed("gnoswap"), "a challenge is an objection, not a verdict") // One address, one vote, and only while the window is open. testing.SetRealm(testing.NewUserRealm(carol)) Vote(cross(cur), "gnoswap", false) testing.SetRealm(testing.NewUserRealm(carol)) uassert.AbortsContains(t, cur, "one address, one vote", func() { Vote(cross(cur), "gnoswap", true) }) testing.SkipHeights(ChallengeBlocks) testing.SetRealm(testing.NewUserRealm(dave)) uassert.AbortsContains(t, cur, "deadline has passed", func() { Vote(cross(cur), "gnoswap", true) }) } // The full round, with the coins checked against the chain rather than against // the realm's own books. func TestResolveRemovesAnEntryAndPaysTheChallenger(cur realm, t *testing.T) { reset() apply(cur, alice, "gnoswap", "https://gnoswap.io", "an AMM") challenge(cur, bob, "gnoswap") uassert.Equal(t, 2*Deposit, balance(realmAddr), "both stakes are at the realm") testing.SetRealm(testing.NewUserRealm(carol)) Vote(cross(cur), "gnoswap", false) testing.SetRealm(testing.NewUserRealm(dave)) Vote(cross(cur), "gnoswap", false) testing.SetRealm(testing.NewUserRealm(carol)) uassert.AbortsContains(t, cur, "still open", func() { Resolve(cross(cur), "gnoswap") }) testing.SkipHeights(ChallengeBlocks) // Anyone may settle it, including somebody with nothing on the outcome. testing.SetRealm(testing.NewUserRealm(carol)) Resolve(cross(cur), "gnoswap") uassert.False(t, IsListed("gnoswap")) uassert.Equal(t, 0, Count()) uassert.Equal(t, 2*Deposit, CreditOf(bob), "the bond back, plus alice's deposit") uassert.Equal(t, int64(0), CreditOf(alice)) uassert.Equal(t, int64(0), CreditOf(carol), "voters are paid nothing in v0") uassert.Equal(t, 2*Deposit, balance(realmAddr), "credited is not sent") before := balance(bob) testing.SetRealm(testing.NewUserRealm(bob)) got := Withdraw(cross(cur)) uassert.Equal(t, 2*Deposit, got) uassert.Equal(t, before+2*Deposit, balance(bob), "the coins actually arrived") uassert.Equal(t, int64(0), balance(realmAddr), "and the realm kept nothing") // A second call, which is what a reentrant one would be, finds nothing. testing.SetRealm(testing.NewUserRealm(bob)) uassert.AbortsContains(t, cur, "nothing to withdraw", func() { Withdraw(cross(cur)) }) } func TestATieKeepsTheEntryAndPaysTheOwner(cur realm, t *testing.T) { reset() apply(cur, alice, "gnoswap", "https://gnoswap.io", "an AMM") challenge(cur, bob, "gnoswap") testing.SetRealm(testing.NewUserRealm(carol)) Vote(cross(cur), "gnoswap", true) testing.SetRealm(testing.NewUserRealm(dave)) Vote(cross(cur), "gnoswap", false) testing.SkipHeights(ChallengeBlocks) testing.SetRealm(testing.NewUserRealm(bob)) Resolve(cross(cur), "gnoswap") uassert.True(t, IsListed("gnoswap"), "the incumbent wins ties") uassert.Equal(t, Deposit, CreditOf(alice), "and takes the challenger's bond") uassert.Equal(t, int64(0), CreditOf(bob)) // Her own deposit is still locked behind the entry, so only the bond is // collectable until she unlists. before := balance(alice) testing.SetRealm(testing.NewUserRealm(alice)) uassert.Equal(t, Deposit, Withdraw(cross(cur))) uassert.Equal(t, before+Deposit, balance(alice)) uassert.Equal(t, Deposit, balance(realmAddr), "the deposit stays behind the entry") } // Nobody voting is a tie too, which is the common case and the one that // decides whether a challenge is cheap. It is not: it costs the bond. func TestAChallengeNobodyVotedOnLoses(cur realm, t *testing.T) { reset() apply(cur, alice, "gnoswap", "https://gnoswap.io", "an AMM") challenge(cur, bob, "gnoswap") testing.SkipHeights(ChallengeBlocks) testing.SetRealm(testing.NewUserRealm(bob)) Resolve(cross(cur), "gnoswap") uassert.True(t, IsListed("gnoswap")) uassert.Equal(t, Deposit, CreditOf(alice)) uassert.Equal(t, int64(0), CreditOf(bob), "being wrong costs the bond") } func TestUnlistIsTheOwnersAndNotWhileChallenged(cur realm, t *testing.T) { reset() apply(cur, alice, "gnoswap", "https://gnoswap.io", "an AMM") testing.SetRealm(testing.NewUserRealm(bob)) uassert.AbortsContains(t, cur, "only the entry's owner", func() { Unlist(cross(cur), "gnoswap") }) challenge(cur, bob, "gnoswap") testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "under challenge", func() { Unlist(cross(cur), "gnoswap") }) uassert.Equal(t, int64(0), CreditOf(alice), "she cannot walk away mid-challenge") testing.SkipHeights(ChallengeBlocks) testing.SetRealm(testing.NewUserRealm(alice)) Resolve(cross(cur), "gnoswap") testing.SetRealm(testing.NewUserRealm(alice)) Unlist(cross(cur), "gnoswap") uassert.False(t, IsListed("gnoswap")) uassert.Equal(t, 2*Deposit, CreditOf(alice), "the bond she won, and her deposit back") // The key is free again, and re-listing it costs a fresh deposit. apply(cur, bob, "gnoswap", "https://mine.io", "mine now") _, _, owner, _, _, state := Get("gnoswap") uassert.Equal(t, bob.String(), owner.String()) uassert.Equal(t, "listed", state) } // The realm holds exactly what it is accountable for, at every step: a stake // behind something live, or a credit somebody has not collected yet. func TestTheRealmHoldsExactlyWhatItOwes(cur realm, t *testing.T) { reset() check := func(step string) { t.Helper() uassert.Equal(t, list.Locked()+list.Owed(), balance(realmAddr), step) } check("empty") apply(cur, alice, "one", "https://1.io", "first") check("after a listing") apply(cur, bob, "two", "https://2.io", "second") check("after a second listing") challenge(cur, bob, "one") check("after a challenge") testing.SetRealm(testing.NewUserRealm(carol)) Vote(cross(cur), "one", false) check("after a vote") testing.SkipHeights(ChallengeBlocks) testing.SetRealm(testing.NewUserRealm(carol)) Resolve(cross(cur), "one") check("after a resolution") testing.SetRealm(testing.NewUserRealm(bob)) Withdraw(cross(cur)) check("after the winner collected") testing.SetRealm(testing.NewUserRealm(bob)) Unlist(cross(cur), "two") check("after an unlisting") testing.SetRealm(testing.NewUserRealm(bob)) Withdraw(cross(cur)) check("after the deposit came back") uassert.Equal(t, int64(0), balance(realmAddr), "and the realm is empty again") } // A URL is caller-supplied too, and it is the one string that reaches the // renderer as a link target rather than as text. func TestAPipeInAUrlCannotOpenAColumn(cur realm, t *testing.T) { reset() apply(cur, alice, "weird", "https://x.io/a|b", "a url with a pipe in it") index := Render("") uassert.False(t, strings.Contains(index, "https://x.io/a|b"), "the raw pipe would open a column: "+index) uassert.True(t, strings.Contains(index, "https://x.io/a%7Cb"), "the link sanitizer percent-encodes it: "+index) } // TestRender uses uassert rather than an Example because every view contains // consecutive blank lines, which gno collapses inside an // Output: block. func TestRender(cur realm, t *testing.T) { reset() apply(cur, alice, "gnoswap", "https://gnoswap.io/pools?a=1", "an AMM | with [a link](x)") index := Render("") uassert.True(t, strings.Contains(index, "# Curated"), index) uassert.True(t, strings.Contains(index, "| 1 | 0 |"), "one listed, none challenged: "+index) uassert.True(t, strings.Contains(index, "func=Apply"), "the index offers the transaction") uassert.True(t, strings.Contains(index, "Voters are paid nothing"), "the index says what v0 does not do") uassert.True(t, strings.Contains(index, "an AMM \\| with \\[a link\\]\\(x\\)"), "a pipe in a description cannot open a column, and its markdown is inert: "+index) entry := Render("entry/gnoswap") uassert.True(t, strings.Contains(entry, "# gnoswap"), entry) uassert.True(t, strings.Contains(entry, "an AMM | with \\[a link\\]\\(x\\)"), "the description is escaped in prose too, where a pipe is not special: "+entry) uassert.True(t, strings.Contains(entry, "func=Challenge")) uassert.True(t, strings.Contains(entry, "func=Unlist")) // Under challenge the page offers the two votes instead. challenge(cur, bob, "gnoswap") entry = Render("entry/gnoswap") uassert.True(t, strings.Contains(entry, "## Under challenge"), entry) uassert.True(t, strings.Contains(entry, "keep=true"), entry) uassert.True(t, strings.Contains(entry, "keep=false"), entry) uassert.False(t, strings.Contains(entry, "func=Unlist"), "an owner cannot walk away here either") uassert.True(t, strings.Contains(Render(""), "## Open challenges")) testing.SetRealm(testing.NewUserRealm(carol)) Vote(cross(cur), "gnoswap", false) testing.SetRealm(testing.NewUserRealm(dave)) Vote(cross(cur), "gnoswap", false) // Past the deadline it offers the settlement instead of the votes. testing.SkipHeights(ChallengeBlocks) entry = Render("entry/gnoswap") uassert.True(t, strings.Contains(entry, "func=Resolve"), entry) uassert.False(t, strings.Contains(entry, "keep=true"), "voting is over: "+entry) // Removed, the key is advertised as free again. testing.SetRealm(testing.NewUserRealm(bob)) Resolve(cross(cur), "gnoswap") entry = Render("entry/gnoswap") uassert.True(t, strings.Contains(entry, "free to apply for again"), entry) uassert.True(t, strings.Contains(Render(""), "Nothing is on the list yet.")) uassert.Equal(t, "# Curated\nNo entry named nope", Render("entry/nope")) uassert.True(t, strings.HasPrefix(Render("elsewhere"), "# Not found")) } // A realm that forwards a user's call must not be able to spend the user's // envelope as if it were its own. OriginSend reports what the SIGNER attached // to the transaction, so without the IsUserCall guard a realm that received // one GNOT could call in here repeatedly, each call reading the same send and // listing another entry for free. That is r/moul/grant Fund, and gnovet's // origin-send-unguarded rule caught this exact shape here in CI after the // local gate had passed. func TestOnlyAUserCanPayIn(cur realm, t *testing.T) { reset() pay(Deposit) testing.SetRealm(testing.NewCodeRealm("gno.land/r/g1relay/forwarder")) uassert.AbortsContains(t, cur, "must be a direct user transaction", func() { Apply(cross(cur), "relayed", "/r/x", "paid with somebody else's coins") }) uassert.Equal(t, 0, Count(), "nothing was listed") apply(cur, alice, "real", "/r/real", "a real entry") pay(Deposit) testing.SetRealm(testing.NewCodeRealm("gno.land/r/g1relay/forwarder")) uassert.AbortsContains(t, cur, "must be a direct user transaction", func() { Challenge(cross(cur), "real") }) _, _, _, _, _, open := ChallengeOf("real") uassert.False(t, open, "no challenge was opened") }
  8. #8gnomod.toml
  9. #9module = "gno.land/r/moul/x/social/curated/v0" gno = "0.9" private = true # private: nothing imports this realm. It is an application, not a library: the # engine every other realm would want is the p/ package, and the list itself is # read through Render or through the plain reads below, neither of which needs # an import. # # The price is the one private always charges, and it is sharper here than # usual because this realm holds coins: a redeploy keeps the balance at the # realm address and wipes every package-level variable, so the entries, the # challenges and the credit ledger would all go while the money they account # for stayed. A redeploy of a live list is therefore a migration and not a fix, # and anything beyond a patch ships as a v1 beside it.
  10. #10render.gno
  11. #11package curated import ( "strconv" "strings" "chain/runtime" "gno.land/p/moul/kit/num/v0" "gno.land/p/moul/kit/ui/v0" "gno.land/p/moul/md/v0" "gno.land/p/moul/realmpath/v0" "gno.land/p/moul/txlink/v0" cu "gno.land/p/moul/x/social/curated/v0" ) // Render routes two views: the list itself, and one entry. // // A key never contains a slash, so "entry/<key>" is a prefix strip rather than // a path element that would have to be rejoined. func Render(path string) string { req := realmpath.Parse(path) switch { case strings.HasPrefix(req.Path, "entry/"): return renderEntry(strings.TrimPrefix(req.Path, "entry/")) case req.Path == "": return renderIndex() default: return md.H1("Not found") + "No such view: " + ui.Inline(req.Path) } } func renderIndex() string { out := md.H1("Curated") out += "A list you pay to be on, and can be paid for getting somebody off. " + "The deposit does not make an entry good: it makes a bad one expensive " + "to leave standing, because anybody who disagrees can put the same " + "amount at risk and take it.\n\n" open := list.Challenged() t := ui.NewTable("on the list", "open challenges", "deposit", "window", "locked", "credited") t.Row( strconv.Itoa(list.Count()), strconv.Itoa(len(open)), num.GNOTf(Deposit), strconv.FormatInt(ChallengeBlocks, 10)+" blocks", num.GNOTf(list.Locked()), num.GNOTf(list.Owed()), ) out += t.String() out += md.H2("The list") out += listing(list.Listed()) out += "\n" + md.Link("➕ Apply for "+num.GNOTf(Deposit), applyURL()) + "\n" if len(open) > 0 { out += md.H2("Open challenges") out += challengeTable(open) } out += md.H2("How it works") out += md.BulletList([]string{ "**Apply** locks " + num.GNOTf(Deposit) + " and lists the entry immediately. " + "There is no application period in v0.", "**Challenge** matches that entry's deposit with a bond and opens a vote " + "for " + strconv.FormatInt(ChallengeBlocks, 10) + " blocks. An owner cannot challenge their own entry.", "**Vote** is one address, one vote, unweighted. An address is free, so read " + "a resolution as nobody with a stake objecting, never as a verdict.", "**Resolve** can be called by anyone once the window closes. A majority to " + "keep credits the owner the bond; otherwise the challenger takes the bond " + "and the deposit. A tie keeps the entry, so a challenge that convinced " + "nobody loses its bond.", "**Voters are paid nothing** in v0, which is the first thing to fix: the only " + "two addresses with a reason to vote are the ones with money on the outcome.", "**Nothing is ever sent to you.** Every payout is a credit, and " + md.InlineCode("Withdraw()") + " collects it.", }) return out } func renderEntry(key string) string { e, ok := list.Get(key) if !ok { return md.H1("Curated") + "No entry named " + ui.Inline(key) } out := md.H1(ui.Inline(e.Key)) out += md.Blockquote(ui.Inline(e.Description)) + "\n" // ShortN truncates without escaping and md.Link escapes what it is given: // ui.Inline here would escape twice and render the backslashes. out += md.Link(ui.ShortN(e.URL, 60, 0), e.URL) + "\n\n" t := ui.NewTable("owner", "deposit", "listed at", "state") t.Row( ui.Addr(e.Owner), num.GNOTf(e.Deposit), "block "+strconv.FormatInt(e.At, 10), e.State.String(), ) out += t.String() c, challenged := list.ChallengeOf(e.Key) switch { case challenged: out += md.H2("Under challenge") out += challengeTable([]*cu.Entry{e}) if c.Open(runtime.ChainHeight()) { out += "\n" + ui.Action("👍 Keep", "Vote", "key", e.Key, "keep", "true") + " · " + ui.Action("👎 Remove", "Vote", "key", e.Key, "keep", "false") + "\n" } else { out += "\n" + ui.Action("⚖️ Resolve", "Resolve", "key", e.Key) + " · the window has closed and anyone can settle it\n" } case e.Live(): out += "\n" + md.Link("🚩 Challenge for "+num.GNOTf(e.Deposit), challengeURL(e.Key, e.Deposit)) + " · " + ui.Action("🗑 Unlist", "Unlist", "key", e.Key) + "\n" default: out += "\n" + ui.Empty("Off the list. The key is free to apply for again.") out += md.Link("➕ Apply for "+num.GNOTf(Deposit), applyURL()) + "\n" } return out } // listing renders the entries as the list itself: the key links to its page, // the description links to the thing it describes. func listing(entries []*cu.Entry) string { if len(entries) == 0 { return ui.Empty("Nothing is on the list yet.") } t := ui.NewTable("key", "what", "link", "owner", "since", "state") for _, e := range entries { mark := "" if e.State == cu.StateChallenged { mark = " 🚩" } t.Row( // A key is charset-validated, so md.Link's escaping is the whole // story for it. A description is not: md.Link escapes markdown // but NOT the pipe, which in a cell would open a column, so the // description is never a link title here. Cut first with ShortN, // which does not escape, then escape once with ui.Cell. md.Link(ui.ShortN(e.Key, 32, 0), entryURL(e.Key)), ui.Cell(ui.ShortN(e.Description, 48, 0)), md.Link("↗", e.URL), ui.Addr(e.Owner), "block "+strconv.FormatInt(e.At, 10), e.State.String()+mark, ) } return t.String() } // challengeTable renders the open challenge against each of entries. An entry // with none is skipped rather than shown blank. func challengeTable(entries []*cu.Entry) string { t := ui.NewTable("entry", "challenger", "bond", "keep", "remove", "voting closes") now := runtime.ChainHeight() for _, e := range entries { c, ok := list.ChallengeOf(e.Key) if !ok { continue } closes := "block " + strconv.FormatInt(c.Deadline, 10) if !c.Open(now) { closes = "closed, awaiting `Resolve`" } t.Row( md.Link(ui.ShortN(e.Key, 32, 0), entryURL(e.Key)), ui.Addr(c.Challenger), num.GNOTf(c.Bond), strconv.FormatInt(c.Keep, 10), strconv.FormatInt(c.Remove, 10), closes, ) } return t.OrEmpty("No challenge is open.") } // entryURL is the gnoweb path of one entry's page. // // The chain domain is the first element of a package path and a gnoweb path is // the rest of it, so this is a prefix strip and not a hostname to hardcode. func entryURL(key string) string { path := realmPath if i := strings.Index(path, "/"); i >= 0 { path = path[i:] } return path + ":entry/" + key } // applyURL is the Apply form with the deposit already attached, so a reader // never has to work out what -send should say. func applyURL() string { return txlink.NewLink("Apply").SetSend(send(Deposit)).URL() } // challengeURL is the same for Challenge, carrying that entry's own bond. func challengeURL(key string, bond int64) string { return txlink.NewLink("Challenge").AddArgs("key", key).SetSend(send(bond)).URL() } func send(amount int64) string { return strconv.FormatInt(amount, 10) + Denom }
#6AddPackagegno.land/r/moul/x/social/patron/v011 arguments
Attached funds
14000000ugnot

Arguments · 11

  1. #1patron
  2. #2README.md
  3. #3# `gno.land/r/moul/x/social/patron/v0` **Recurring support for a builder, on chain.** A creator opens a plan with a price per period and a period measured in blocks; a supporter attaches ugnot to `Subscribe` and buys whole periods of it; anyone can ask at any height whether a given address is still active. ``` Open(title, description, pricePerPeriod, periodBlocks) -> planID Subscribe(planID) payable, returns the new paid-through height Close(planID) / Reopen(planID) creator only Withdraw() earnings and change leave the same way IsActive(planID, who) the one question a tip jar cannot answer ``` **There is no cron on chain, so a renewal is a pull and not a push.** Nothing here can charge anybody: a renewal happens because the supporter sends another payment, never because a timer fired, and a native coin cannot be pulled at all. A reader arriving from web2 expects a standing mandate on a card; there is nothing of the kind anywhere on this chain, and that is the single most important sentence on this page. `r/moul/x/daily/tipjar` is the one-shot version and is already live. The recurrence is the whole difference, and it makes this the one app in the `x/social` family that produces a recurring write rather than a one-off. **Renewing early never discards time already paid for**: an extension runs from whichever is later, now or the current paid-through. Renewing after lapsing runs from now. A payment short of one period is refused rather than partially credited, and the remainder under a whole period is **credited back to the supporter**, because keeping it would be a silent fee and a subscription realm is exactly where a silent fee must not be. **Earnings are credited at payment time, not streamed.** The creator can withdraw the full price the instant it is paid, and a supporter who stops being active is not refunded. That is a deliberate v0 limitation: escrowed streaming, where the creator claims only what has elapsed and the supporter reclaims the rest, needs a claim schedule and a refund path, which is a larger realm rather than a flag on this one. Both halves of what the realm owes, a creator's earnings and a supporter's change, land in one credit ledger and leave through `Withdraw`, which zeroes the credit before any coin moves. `EarnedBy` and `CreditOf` are deliberately different numbers: lifetime credited against withdrawable now. `Subscribe` is payable and therefore has to be called **directly by a user**. A realm called by another realm cannot forward the envelope and `maketx run` cannot reach a payable function at all, so there is no path where another contract subscribes on somebody's behalf. ## The token question **v0 ships none, and the condition for yes is written down** in [the engine's README](https://github.com/moul/gno-contracts/tree/main/p/moul/x/social/patron): a creator coin is easy to mint and has no sink, because what a supporter would redeem it for is a promise made off chain. What would change the answer is a redeem the chain can enforce. The slot it would drop into is [`p/moul/x/social/coin`](https://github.com/moul/gno-contracts/tree/main/p/moul/x/social/coin), a GRC20 that refuses to exist until its mint rule, its sink and its buyer are declared. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **On mainnet:** [![deployment status](https://gnoscope.com/_badges/shield/status/r/moul/x/social/patron/v0?network=mainnet)](https://gnoscope.com/realm/r/moul/x/social/patron/v0) [![transactions](https://gnoscope.com/_badges/shield/txs/r/moul/x/social/patron/v0?network=mainnet)](https://gnoscope.com/realm/r/moul/x/social/patron/v0) [![unique callers](https://gnoscope.com/_badges/shield/users/r/moul/x/social/patron/v0?network=mainnet)](https://gnoscope.com/realm/r/moul/x/social/patron/v0) [![deployed revision](https://gnoscope.com/_badges/shield/version/r/moul/x/social/patron/v0?network=mainnet)](https://gnoscope.com/realm/r/moul/x/social/patron/v0) **Dependency graph:** ![gno.land/r/moul/x/social/patron/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/x/social/patron/v0/deps.png) > 🧪 **Highly experimental — potentially vibe-coded.** Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
  4. #4gnomod.toml
  5. #5module = "gno.land/r/moul/x/social/patron/v0" gno = "0.9" private = true
  6. #6patron.gno
  7. #7// Package patron is recurring support for a builder, on chain. // // A creator opens a plan with a price per period and a period measured in // blocks. A supporter attaches ugnot to [Subscribe] and buys whole periods of // it. Anybody can ask at any height whether a given address is still active, // which is the one question a tip cannot answer. // // # There is no cron on chain, so a renewal is a pull and not a push // // Nothing in this realm can charge anybody. A renewal happens because the // supporter sends another payment, never because a timer fired: there is no // scheduler here, and there is no way to write one, since a native coin // cannot be pulled at all. A reader arriving from web2 expects a standing // mandate on a card and there is nothing of the kind, anywhere on this chain. // // # What this adds over the tip jar // // gno.land/r/moul/x/daily/tipjar is the one-shot version and is already live: // one payment, a leaderboard, and then nothing. The recurrence is the whole // difference. A plan has a price and a period, a payment buys whole periods // of it, renewing early never discards time already paid for, and the realm // answers "is this address active right now" at any height. It is the one app // in the x/social family that produces a recurring write rather than a // one-off. // // # Earnings are credited at payment time, not streamed // // The creator can withdraw the full price the instant it is paid. A supporter // who stops being active is NOT refunded, and no part of a paid period ever // comes back. That is a deliberate v0 limitation: escrowed streaming, where // the creator claims only what has elapsed and the supporter cancels and // reclaims the rest, needs a claim schedule and a refund path, and that is a // larger realm than this one rather than a flag on it. // // # Money leaves by pull // // Both halves of what this realm owes, a creator's earnings and a supporter's // change, land in one credit ledger and leave through [Withdraw]. The credit // is zeroed before any coin moves, and the realm never loops over payees: one // unpayable address would otherwise fail the whole batch and hand a griefer a // cheap denial of service. // // # v0 ships no token // // Deliberately, and the README says why: a creator coin minted per period // paid is easy, and the sink is not, because what it would be redeemed for is // a promise made off chain. package patron import ( "chain" "chain/banker" "chain/runtime" "strconv" "gno.land/p/moul/kit/store/v0" "gno.land/p/moul/x/envelope/v0" pt "gno.land/p/moul/x/social/patron/v0" ) // realmPath is this realm's own path, the one its gnomod.toml module line // declares. It is written out rather than read from the frame, because a // plain read exported by a realm reports the CALLER and would build every // link against whoever asked. const realmPath = "gno.land/r/moul/x/social/patron/v0" // denom is the only coin this realm handles. const denom = "ugnot" // plans holds every plan and the credit ledger. A redeploy wipes it, which is // the trade `private = true` makes: see gnomod.toml. var plans = pt.NewRegistry() // Open creates a plan and returns its id. Anyone may open one, and opening // one costs nothing beyond gas and the storage deposit. // // pricePerPeriod is in ugnot and must be at least one: a free plan is a tip // jar, not a subscription. periodBlocks is a period in BLOCKS, bounded both // ways by the engine, because height is the clock consensus agrees on and a // block timestamp is not something to build a billing cliff out of. func Open(cur realm, title, description string, pricePerPeriod, periodBlocks int64) int64 { who := caller(cur) id, err := plans.Open(who, title, description, pricePerPeriod, periodBlocks) if err != nil { panic(err.Error()) } chain.Emit("Open", "id", id.String(), "creator", who.String(), "price", strconv.FormatInt(pricePerPeriod, 10), "period", strconv.FormatInt(periodBlocks, 10)) return int64(id) } // Subscribe buys whole periods on a plan with the ugnot attached to the call, // and returns the caller's new paid-through height. // // Attach the coins with -send: a payment short of one period is refused, and // the remainder under one period is credited back to the caller rather than // kept, so an overpayment is change and never a silent fee. Take it back with // [Withdraw]. // // # It has to be called directly by a user, and that is not a style choice // // The envelope is what the SIGNER attached to the transaction. A realm the // user called has already received those coins itself, and could then call in // here as many times as it liked against one payment, so the caller is // checked before the envelope is read. The same property means a realm cannot // forward an envelope it was handed (`NewBanker` requires the previous frame // to be a user call), and that `maketx run` cannot reach this function at all, // because a run script is itself a code realm. Use `maketx call -send`. func Subscribe(cur realm, planID int64) int64 { // The frame is read here rather than through caller(), because this // function asks the previous frame two questions and the order of them is // the whole guard: is the token live, is the caller a user, and only then // what did they send. if !cur.IsCurrent() { panic("spoofed realm: cur is not the live crossing frame") } prev := cur.Previous() if !prev.IsUserCall() { panic("patron: Subscribe must be called directly by a user with maketx call -send, " + "not through another realm and not from maketx run") } who := prev.Address() id := store.ID(planID) p := mustGet(id) if !p.Open { panic(pt.ErrPlanClosed.Error()) } // RequireAtLeast names both numbers when the envelope falls short, which // is the difference between a caller fixing their command and a caller // opening their wallet. sent := envelope.RequireAtLeast(denom, p.PricePerPeriod) pay, err := plans.Subscribe(id, who, sent, runtime.ChainHeight()) if err != nil { panic(err.Error()) } chain.Emit("Subscribe", "id", id.String(), "supporter", who.String(), "periods", strconv.FormatInt(pay.Periods, 10), "spent", strconv.FormatInt(pay.Spent, 10), "change", strconv.FormatInt(pay.Change, 10), "paidThrough", strconv.FormatInt(pay.PaidThrough, 10)) return pay.PaidThrough } // Close stops a plan taking new subscriptions. Creator only. // // Everything already paid for runs to its own paid-through height. Closing a // plan is not a way to take a period back. func Close(cur realm, planID int64) { who := caller(cur) id := store.ID(planID) if err := plans.Close(id, who); err != nil { panic(err.Error()) } chain.Emit("Close", "id", id.String(), "by", who.String()) } // Reopen lets a closed plan take subscriptions again. Creator only. func Reopen(cur realm, planID int64) { who := caller(cur) id := store.ID(planID) if err := plans.Reopen(id, who); err != nil { panic(err.Error()) } chain.Emit("Reopen", "id", id.String(), "by", who.String()) } // Withdraw pays the caller everything this realm owes them and returns the // amount: a creator's earnings, a supporter's change, or both at once. // // The credit is zeroed by the engine before a coin moves, so a recipient that // calls straight back in finds nothing left to take. func Withdraw(cur realm) int64 { who := caller(cur) amount, err := plans.Withdraw(who) if err != nil { panic(err.Error()) } banker.NewBanker(banker.BankerTypeRealmSend, cur).SendCoins( cur.Address(), who, chain.NewCoins(chain.NewCoin(denom, amount))) chain.Emit("Withdraw", "to", who.String(), "amount", strconv.FormatInt(amount, 10)) return amount } // Get returns a plan's fields. // // It returns a tuple rather than the stored record: handing out a pointer to // realm state is a live mutation handle, and this realm is private, so an // importer retaining one of its objects would be a runtime panic rather than // a design debate. func Get(planID int64) (creator address, title, description string, pricePerPeriod, periodBlocks int64, open bool) { p := mustGet(store.ID(planID)) return p.Creator, p.Title, p.Description, p.PricePerPeriod, p.PeriodBlocks, p.Open } // Count is how many plans exist. func Count() int { return plans.Count() } // IsActive reports whether who is paid up on this plan at the current height. // // Active means now < paidThrough, strictly: an address paid through height h // is active at h-1 and not at h. func IsActive(planID int64, who address) bool { return mustGet(store.ID(planID)).IsActive(who, runtime.ChainHeight()) } // PaidThrough is the height who stops being active at on this plan, or zero // if they never paid. func PaidThrough(planID int64, who address) int64 { return mustGet(store.ID(planID)).PaidThrough(who) } // SupporterCount is how many distinct addresses have ever paid this plan, // active or not. func SupporterCount(planID int64) int { return mustGet(store.ID(planID)).SupporterCount() } // Supporters is every address that has ever paid this plan, in first-payment // order. It is a copy, not the stored slice. func Supporters(planID int64) []address { return mustGet(store.ID(planID)).Supporters() } // EarnedBy is the lifetime ugnot credited to creator across every plan, // whether or not it has been withdrawn. func EarnedBy(creator address) int64 { return plans.EarnedBy(creator) } // CreditOf is what addr can withdraw right now. func CreditOf(addr address) int64 { return plans.CreditOf(addr) } // TotalOwed is everything this realm owes, which is what it must keep in // reserve at its own address. func TotalOwed() int64 { return plans.TotalOwed() } // mustGet reads a plan or aborts naming it. func mustGet(id store.ID) *pt.Plan { p, ok := plans.Get(id) if !ok { panic("patron: plan #" + id.String() + " not found") } return p } // caller is the address that called us, checked the one way that is safe. func caller(cur realm) address { if !cur.IsCurrent() { panic("spoofed realm: cur is not the live crossing frame") } return cur.Previous().Address() }
  8. #8patron_test.gno
  9. #9package patron import ( "strconv" "strings" "testing" "chain" "chain/banker" "chain/runtime" pt "gno.land/p/moul/x/social/patron/v0" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" "gno.land/p/nt/urequire/v0" ) const ( price = int64(1000000) // 1 GNOT per period period = int64(100) // blocks ) var ( alice = testutils.TestAddress("alice") // the creator, throughout bob = testutils.TestAddress("bob") carol = testutils.TestAddress("carol") realmAddr = chain.PackageAddress(realmPath) ) // reset clears realm state. Realm globals live for the whole test binary, so // everything a later test or the Render tests read starts here. func reset() { plans = pt.NewRegistry() } // pay stages a payment the way the chain does: the coins land at the realm's // address before a line of its code runs, and OriginSend is what the crossing // function then reads. func pay(amount int64) { testing.IssueCoins(realmAddr, chain.Coins{{denom, amount}}) testing.SetOriginSend(chain.Coins{{denom, amount}}) } // open creates a plan as alice. SetRealm is call-frame scoped, so the account // is switched here and then crossed from here, never from a helper that does // not itself cross. func open(cur realm, t *testing.T) int64 { t.Helper() testing.SetRealm(testing.NewUserRealm(alice)) return Open(cross(cur), "monthly support", "a plan with a | pipe in it", price, period) } func TestOpenStoresWhatItWasGiven(cur realm, t *testing.T) { reset() id := open(cur, t) creator, title, desc, p, blocks, isOpen := Get(id) uassert.Equal(t, alice.String(), creator.String()) uassert.Equal(t, "monthly support", title) uassert.Equal(t, "a plan with a | pipe in it", desc) uassert.Equal(t, price, p) uassert.Equal(t, period, blocks) uassert.True(t, isOpen) uassert.Equal(t, 1, Count()) uassert.Equal(t, 0, SupporterCount(id)) } func TestOpenRefusesAFreePlanAndASillyPeriod(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "at least one ugnot per period", func() { Open(cross(cur), "free", "", 0, period) }) testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "period out of range", func() { Open(cross(cur), "instant", "", price, 0) }) testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "period out of range", func() { Open(cross(cur), "forever", "", price, pt.MaxPeriodBlocks+1) }) testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "title is empty", func() { Open(cross(cur), " ", "", price, period) }) uassert.Equal(t, 0, Count()) } // One period, paid exactly, credited to the creator at payment time and // withdrawable as real coins in this same test, because the bank does not // carry over between test functions. func TestOnePeriodIsBoughtAndTheCreatorIsPaid(cur realm, t *testing.T) { reset() id := open(cur, t) start := runtime.ChainHeight() pay(price) testing.SetRealm(testing.NewUserRealm(bob)) through := Subscribe(cross(cur), id) uassert.Equal(t, start+period, through) uassert.Equal(t, start+period, PaidThrough(id, bob)) uassert.True(t, IsActive(id, bob)) uassert.Equal(t, 1, SupporterCount(id)) uassert.Equal(t, price, EarnedBy(alice)) uassert.Equal(t, price, CreditOf(alice)) uassert.Equal(t, int64(0), CreditOf(bob), "an exact payment leaves no change") uassert.Equal(t, price, TotalOwed()) ro := banker.NewReadonlyBanker() before := ro.GetCoin(alice, denom) testing.SetRealm(testing.NewUserRealm(alice)) got := Withdraw(cross(cur)) uassert.Equal(t, price, got) uassert.Equal(t, price, ro.GetCoin(alice, denom)-before, "the creator was really paid") uassert.Equal(t, int64(0), CreditOf(alice)) uassert.Equal(t, price, EarnedBy(alice), "earnings outlive the withdrawal") uassert.Equal(t, int64(0), TotalOwed()) } func TestThreePeriodsAtOnce(cur realm, t *testing.T) { reset() id := open(cur, t) start := runtime.ChainHeight() pay(3 * price) testing.SetRealm(testing.NewUserRealm(bob)) through := Subscribe(cross(cur), id) uassert.Equal(t, start+3*period, through) uassert.Equal(t, 3*price, CreditOf(alice)) uassert.Equal(t, int64(0), CreditOf(bob)) } // Renewing before the lapse extends from paidThrough, so the time already // paid for is still there afterwards. func TestRenewingEarlyExtendsRatherThanRestarts(cur realm, t *testing.T) { reset() id := open(cur, t) pay(price) testing.SetRealm(testing.NewUserRealm(bob)) first := Subscribe(cross(cur), id) // Ten blocks in, with ninety still paid for. testing.SkipHeights(10) pay(price) testing.SetRealm(testing.NewUserRealm(bob)) second := Subscribe(cross(cur), id) uassert.Equal(t, first+period, second, "the second period starts where the first ended") uassert.True(t, second > runtime.ChainHeight()+period, "more than one period is left, which is the point") uassert.Equal(t, 1, SupporterCount(id), "renewing is not a second supporter") } // Renewing after a lapse starts from now: the gap was never paid for. func TestRenewingAfterALapseStartsFromNow(cur realm, t *testing.T) { reset() id := open(cur, t) pay(price) testing.SetRealm(testing.NewUserRealm(bob)) Subscribe(cross(cur), id) testing.SkipHeights(500) uassert.False(t, IsActive(id, bob), "the first period lapsed long ago") pay(price) testing.SetRealm(testing.NewUserRealm(bob)) second := Subscribe(cross(cur), id) uassert.Equal(t, runtime.ChainHeight()+period, second) uassert.True(t, IsActive(id, bob)) } // IsActive flips at the block the period ends on, and not a block either side. func TestIsActiveFlipsAtTheRightHeight(cur realm, t *testing.T) { reset() id := open(cur, t) start := runtime.ChainHeight() pay(price) testing.SetRealm(testing.NewUserRealm(bob)) Subscribe(cross(cur), id) testing.SetHeight(start + period - 1) uassert.True(t, IsActive(id, bob), "the last block of the period") testing.SetHeight(start + period) uassert.False(t, IsActive(id, bob), "the first block after it") uassert.False(t, IsActive(id, carol), "an address that never paid is never active") uassert.Equal(t, int64(0), PaidThrough(id, carol)) } func TestAPaymentShortOfOnePeriodIsRefused(cur realm, t *testing.T) { reset() id := open(cur, t) pay(price - 1) testing.SetRealm(testing.NewUserRealm(bob)) uassert.AbortsContains(t, cur, "this call costs at least", func() { Subscribe(cross(cur), id) }) uassert.Equal(t, int64(0), PaidThrough(id, bob)) uassert.Equal(t, int64(0), EarnedBy(alice)) uassert.Equal(t, 0, SupporterCount(id)) } // The remainder under one period is the supporter's, not the realm's, and it // comes back out as real coins. func TestChangeIsCreditedBackAndWithdrawable(cur realm, t *testing.T) { reset() id := open(cur, t) pay(2*price + 250000) testing.SetRealm(testing.NewUserRealm(bob)) Subscribe(cross(cur), id) uassert.Equal(t, int64(250000), CreditOf(bob), "the change is the supporter's") uassert.Equal(t, 2*price, CreditOf(alice)) uassert.Equal(t, 2*price+250000, TotalOwed(), "every ugnot in is owed to somebody") ro := banker.NewReadonlyBanker() before := ro.GetCoin(bob, denom) testing.SetRealm(testing.NewUserRealm(bob)) got := Withdraw(cross(cur)) uassert.Equal(t, int64(250000), got) uassert.Equal(t, int64(250000), ro.GetCoin(bob, denom)-before) uassert.Equal(t, int64(0), CreditOf(bob)) testing.SetRealm(testing.NewUserRealm(bob)) uassert.AbortsContains(t, cur, "nothing to withdraw", func() { Withdraw(cross(cur)) }) } func TestWithdrawRefusesSomebodyOwedNothing(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(carol)) uassert.AbortsContains(t, cur, "nothing to withdraw", func() { Withdraw(cross(cur)) }) } // A realm that the user called holds the coins itself and could subscribe // over and over against one payment. The guard runs before the envelope is // read, so it cannot. func TestOnlyAUserCanSubscribe(cur realm, t *testing.T) { reset() id := open(cur, t) pay(price) testing.SetRealm(testing.NewCodeRealm("gno.land/r/g1relay/forwarder")) uassert.AbortsContains(t, cur, "must be called directly by a user", func() { Subscribe(cross(cur), id) }) uassert.Equal(t, int64(0), EarnedBy(alice)) } func TestCloseAndReopenAreTheCreatorsAlone(cur realm, t *testing.T) { reset() id := open(cur, t) pay(price) testing.SetRealm(testing.NewUserRealm(bob)) Subscribe(cross(cur), id) testing.SetRealm(testing.NewUserRealm(bob)) uassert.AbortsContains(t, cur, "only the plan's creator", func() { Close(cross(cur), id) }) testing.SetRealm(testing.NewUserRealm(alice)) Close(cross(cur), id) _, _, _, _, _, isOpen := Get(id) uassert.False(t, isOpen) uassert.True(t, IsActive(id, bob), "closing does not take back a paid period") pay(price) testing.SetRealm(testing.NewUserRealm(carol)) uassert.AbortsContains(t, cur, "closed to new subscriptions", func() { Subscribe(cross(cur), id) }) testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "already closed", func() { Close(cross(cur), id) }) testing.SetRealm(testing.NewUserRealm(bob)) uassert.AbortsContains(t, cur, "only the plan's creator", func() { Reopen(cross(cur), id) }) testing.SetRealm(testing.NewUserRealm(alice)) Reopen(cross(cur), id) testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "already open", func() { Reopen(cross(cur), id) }) pay(price) testing.SetRealm(testing.NewUserRealm(carol)) Subscribe(cross(cur), id) uassert.Equal(t, 2, SupporterCount(id)) } func TestAPlanThatIsNotThere(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(bob)) uassert.AbortsContains(t, cur, "plan #4242 not found", func() { Subscribe(cross(cur), 4242) }) uassert.PanicsContains(t, cur, "plan #4242 not found", func() { Count(); IsActive(4242, bob) }) } func TestSupportersAreDistinctAndInFirstPaymentOrder(cur realm, t *testing.T) { reset() id := open(cur, t) pay(price) testing.SetRealm(testing.NewUserRealm(carol)) Subscribe(cross(cur), id) pay(price) testing.SetRealm(testing.NewUserRealm(bob)) Subscribe(cross(cur), id) pay(price) testing.SetRealm(testing.NewUserRealm(carol)) Subscribe(cross(cur), id) got := Supporters(id) urequire.Equal(t, 2, len(got)) uassert.Equal(t, carol.String(), got[0].String()) uassert.Equal(t, bob.String(), got[1].String()) } // TestRender uses uassert rather than an Example because every view contains // consecutive blank lines, which gno collapses inside an // Output: block. func TestRender(cur realm, t *testing.T) { reset() empty := Render("") uassert.True(t, strings.Contains(empty, "No plans yet."), empty) uassert.True(t, strings.Contains(empty, "no cron on chain"), "the index says it: "+empty) testing.SetRealm(testing.NewUserRealm(alice)) id := Open(cross(cur), "a | pipe and [a link](x)", "about **everything**", price, period) pay(price) testing.SetRealm(testing.NewUserRealm(bob)) Subscribe(cross(cur), id) index := Render("") uassert.False(t, strings.Contains(index, "| a | pipe"), "a raw pipe would open a column: "+index) uassert.True(t, strings.Contains(index, "a \\| pipe and \\[a link\\]\\(x\\)"), "the title is escaped once in the listing, pipe included: "+index) uassert.True(t, strings.Contains(index, "["+strconv.FormatInt(id, 10)+"](/r/moul/x/social/patron/v0:plan/"), "the link text is the plan number, which is ours and not the caller's: "+index) uassert.True(t, strings.Contains(index, "func=Open"), "the index offers the transaction") uassert.True(t, strings.Contains(index, "1 of 1"), "one active of one supporter: "+index) sid := strconv.FormatInt(id, 10) page := Render("plan/" + sid) uassert.True(t, strings.Contains(page, "# Plan #"+sid)) // A heading is prose, so ui.Inline escapes the link syntax and leaves the // pipe alone: a pipe only means something inside a table. uassert.True(t, strings.Contains(page, "# Plan #"+sid+": a | pipe and \\[a link\\]\\(x\\)"), "the title is escaped in the heading too: "+page) uassert.True(t, strings.Contains(page, "about \\*\\*everything\\*\\*"), "the description is escaped: "+page) uassert.True(t, strings.Contains(page, "func=Subscribe")) uassert.True(t, strings.Contains(page, ".send="+strconv.FormatInt(price, 10)+"ugnot"), "the support link carries the price: "+page) uassert.True(t, strings.Contains(page, "func=Close")) testing.SetRealm(testing.NewUserRealm(alice)) Close(cross(cur), id) closed := Render("plan/" + sid) uassert.True(t, strings.Contains(closed, "takes no new subscriptions"), closed) uassert.False(t, strings.Contains(closed, "func=Subscribe"), "a closed plan offers no payment: "+closed) uassert.Equal(t, "# Patron\nNo plan #4242", Render("plan/4242")) uassert.Equal(t, "# Patron\nNot a plan id: nope", Render("plan/nope")) uassert.True(t, strings.HasPrefix(Render("elsewhere"), "# Not found")) // A page past the end is an empty listing, not a panic. uassert.True(t, strings.Contains(Render("?page=99"), "No plans yet.")) uassert.True(t, strings.Contains(Render("?page=nonsense"), "a \\| pipe"), "an unreadable page number falls back to the first") }
  10. #10render.gno
  11. #11package patron import ( "strconv" "strings" "chain/runtime" "gno.land/p/moul/kit/num/v0" "gno.land/p/moul/kit/store/v0" "gno.land/p/moul/kit/ui/v0" "gno.land/p/moul/md/v0" "gno.land/p/moul/realmpath/v0" "gno.land/p/moul/txlink/v0" pt "gno.land/p/moul/x/social/patron/v0" ) // pageSize is how many plans the index lists at once. const pageSize = 20 // titleWidth is how much of a plan's title a listing shows. const titleWidth = 40 // Render routes two views: the index and one plan. func Render(path string) string { req := realmpath.Parse(path) switch { case strings.HasPrefix(req.Path, "plan/"): return renderPlan(strings.TrimPrefix(req.Path, "plan/")) case req.Path == "": return renderIndex(req) default: return md.H1("Not found") + "No such view: " + ui.Inline(req.Path) } } func renderIndex(req *realmpath.Request) string { now := runtime.ChainHeight() out := md.H1("Patron") out += "Recurring support for a builder. A creator opens a plan with a price per " + "period and a period measured in blocks, and a supporter buys whole periods of " + "it with " + md.InlineCode("ugnot") + ".\n\n" out += md.Blockquote("There is no cron on chain, so a renewal is a pull and not a " + "push: nothing here can charge anybody. A supporter renews by paying again, and " + "renewing early never loses time already paid for.") + "\n" stats := ui.NewTable("plans", "owed to creators and supporters", "block") stats.Row( strconv.Itoa(plans.Count()), num.GNOTf(plans.TotalOwed()), strconv.FormatInt(now, 10), ) out += stats.String() out += md.H2("Plans") out += planTable(plans.List(pageNum(req), pageSize), now) out += "\n" + ui.Action("➕ Open a plan", "Open", "title", "", "description", "", "pricePerPeriod", "", "periodBlocks", "") + " · " + ui.Action("💰 Withdraw what I am owed", "Withdraw") + "\n" out += md.H2("What it does not do") out += md.BulletList([]string{ "**It does not charge anybody.** A renewal is a payment the supporter signs.", "**It does not stream.** A creator is credited the whole price at the moment " + "it is paid, so a supporter who stops being active is not refunded and no " + "part of a paid period comes back. Escrowed streaming needs a claim " + "schedule and a refund path, which is the v1.", "**It does not issue a token.** Minting a creator coin per period paid is " + "easy; the sink is not, because what it would be redeemed for is a promise " + "made off chain.", }) return out } func renderPlan(raw string) string { id, ok := store.ParseID(raw) if !ok { return md.H1("Patron") + "Not a plan id: " + ui.Inline(raw) } p, found := plans.Get(id) if !found { return md.H1("Patron") + "No plan #" + id.String() } now := runtime.ChainHeight() out := md.H1("Plan #" + id.String() + ": " + ui.Inline(p.Title)) if p.Description != "" { out += md.Blockquote(ui.Inline(p.Description)) + "\n" } status := "open" if !p.Open { status = "closed to new subscriptions" } facts := ui.NewTable("creator", "price per period", "period", "status") facts.Row( ui.Addr(p.Creator), num.GNOTf(p.PricePerPeriod), strconv.FormatInt(p.PeriodBlocks, 10)+" blocks", status, ) out += facts.String() if p.Open { // The link carries the send amount, so the price is not something a // supporter has to copy out of the table and retype. out += md.Link( "💜 Support for one period ("+num.GNOTf(p.PricePerPeriod)+")", txlink.Realm(realmPath).NewLink("Subscribe"). AddArgs("planID", id.String()). SetSend(strconv.FormatInt(p.PricePerPeriod, 10)+denom). URL(), ) + "\n\n" out += "Paying " + md.InlineCode("n") + " times the price buys " + md.InlineCode("n") + " periods at once. Anything left over under one " + "period is credited back to you, not kept: take it with " + md.InlineCode("Withdraw") + ".\n" } else { out += ui.Empty("This plan takes no new subscriptions. Anything already paid " + "for runs to its own block.") } out += md.H2("Supporters") out += supporterTable(p, now) out += md.H2("Creator") out += "Earned across every plan: " + md.Bold(num.GNOTf(plans.EarnedBy(p.Creator))) + " · withdrawable now: " + md.Bold(num.GNOTf(plans.CreditOf(p.Creator))) + "\n\n" out += ui.Action("🚪 Close", "Close", "planID", id.String()) + " · " + ui.Action("🔓 Reopen", "Reopen", "planID", id.String()) + " · " + ui.Action("💰 Withdraw", "Withdraw") + "\n\n" out += md.Link("← every plan", pt.RealmURL(realmPath)) + "\n" return out } // planTable lists plans. // // The link text is the plan NUMBER and not its title, which is the one thing // to copy from here. md.Link escapes its text with the inline escaper, and the // inline escaper does not touch a pipe, because a pipe means nothing in a // sentence: a user title carried inside a link inside a table cell therefore // still opens a column. So the title gets its own cell and goes through // ui.Cell, which is the escaper that knows it is in a table. It is truncated // first with ui.ShortN, which does not escape, so the cut can never strand a // backslash that the escaper had just inserted. func planTable(items []pt.Listing, now int64) string { t := ui.NewTable("#", "plan", "creator", "per period", "period", "supporters", "") for _, it := range items { mark := "🟢" if !it.Plan.Open { mark = "🔒" } t.Row( md.Link(it.ID.String(), pt.PlanURL(realmPath, it.ID)), ui.Cell(ui.ShortN(it.Plan.Title, titleWidth, 0)), ui.Addr(it.Plan.Creator), num.GNOTf(it.Plan.PricePerPeriod), strconv.FormatInt(it.Plan.PeriodBlocks, 10)+" blocks", strconv.Itoa(it.Plan.ActiveCount(now))+" of "+strconv.Itoa(it.Plan.SupporterCount()), mark, ) } return t.OrEmpty("No plans yet.") } func supporterTable(p *pt.Plan, now int64) string { t := ui.NewTable("supporter", "paid through block", "active") for _, who := range p.Supporters() { active := "no" if p.IsActive(who, now) { active = "yes" } t.Row(ui.Addr(who), strconv.FormatInt(p.PaidThrough(who), 10), active) } return t.OrEmpty("Nobody supports this plan yet.") } // pageNum reads ?page= and falls back to the first page for anything it // cannot read, which is the only answer a Render can give: there is nobody to // report an error to. func pageNum(req *realmpath.Request) int { n, err := strconv.Atoi(req.Query.Get("page")) if err != nil || n < 1 { return 1 } return n }
#7AddPackagegno.land/r/moul/x/social/threads/v011 arguments
Attached funds
12000000ugnot

Arguments · 11

  1. #1threads
  2. #2README.md
  3. #3# `gno.land/r/moul/x/social/threads/v0` **A discussion block any realm can embed**, keyed on the page it appears on rather than on the realm that stores it. Two lines in the host realm, which stores nothing: ```go import "gno.land/r/moul/x/social/threads/v0" func Render(path string) string { return body + threads.RenderBlock() } ``` `RenderBlock` takes no argument because it does not need one. A plain read exported by a realm and called by another opens no realm frame, so `unsafe.CurrentRealm()` inside it reports the **caller**: the host realm's own path is the page key, and nothing has to be configured, registered or passed. `RenderBlockFor(page)` is the explicit form, for a realm that renders several pages or already holds its path as a constant. Adding a `cur realm` parameter to either would silently reverse that and key every embed to this realm. It is the text half of [`r/moul/reactions`](https://github.com/moul/gno-contracts/tree/main/r/moul/reactions): the same embed shape, the same page keys, and the same reason for existing. ## The token, and the three things it has to answer THREAD is a GRC20 issued through [`p/moul/x/social/coin`](https://github.com/moul/gno-contracts/tree/main/p/moul/x/social/coin), which refuses to create a token until all three of these are written down: | | | |---|---| | **mint** | 1 THREAD to a thread's author, the first time each distinct address replies to it. People, not messages, and never yourself. | | **sink** | `Boost` burns 10 THREAD to pin a thread to the top of its page for 1,000 blocks. It is the only thing that removes supply, and the only thing the token does. | | **buyer** | anyone who wants placement and has not earned it. They have to acquire THREAD from somebody who did, with `Transfer`. | **Placement is the one thing a discussion has that people want and cannot all have at once**, which is what makes it a sink worth paying for rather than a leaderboard. A pin extends from whichever is later, now or the current pin, so buying one never shortens somebody else's, and `Boost` charges before it pins. **It is farmable, and saying so is cheaper than pretending otherwise**: two addresses replying to each other mint one THREAD each per thread. The fix is a gate on who counts as a replier, which belongs in [`r/moul/x/social/vouch`](https://github.com/moul/gno-contracts/tree/main/r/moul/x/social/vouch) and not here. Until then the cost of farming is one transaction per point and the benefit is capped by what a pin is worth. The token is registered with `r/nt/grc20reg`, so a realm that does not import this one can still find and move it by key: `TokenKey()` returns it. ## Why this realm is not private Every other realm in the `x/social` family is `private = true` and can be redeployed in place. This one cannot be, twice over: a private realm cannot be imported at all, which would delete the embed, and handing an object to another realm (registering the token) panics at runtime from a private realm. Every thread, balance and pin under this path belongs to the people who wrote them, so a fix ships as a `v1` beside it rather than as a redeploy that wipes the lot. ## Reading it `Render("")` is the index: the counts, the recent threads across every page, the token's three declarations, and the embed snippet. `Render("page/<page key>")` is one page's threads, `Render("thread/<id>")` is one thread with its replies and the reply and boost buttons. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **On mainnet:** [![deployment status](https://gnoscope.com/_badges/shield/status/r/moul/x/social/threads/v0?network=mainnet)](https://gnoscope.com/realm/r/moul/x/social/threads/v0) [![transactions](https://gnoscope.com/_badges/shield/txs/r/moul/x/social/threads/v0?network=mainnet)](https://gnoscope.com/realm/r/moul/x/social/threads/v0) [![unique callers](https://gnoscope.com/_badges/shield/users/r/moul/x/social/threads/v0?network=mainnet)](https://gnoscope.com/realm/r/moul/x/social/threads/v0) [![deployed revision](https://gnoscope.com/_badges/shield/version/r/moul/x/social/threads/v0?network=mainnet)](https://gnoscope.com/realm/r/moul/x/social/threads/v0) **Dependency graph:** ![gno.land/r/moul/x/social/threads/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/x/social/threads/v0/deps.png) > 🧪 **Highly experimental — potentially vibe-coded.** Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
  4. #4gnomod.toml
  5. #5module = "gno.land/r/moul/x/social/threads/v0" gno = "0.9" # public: the entire point of this realm is that other realms import it and # call RenderBlock() inside their own Render, exactly as r/moul/reactions does. # A private realm cannot be imported at all, so `private = true` here would # delete the feature rather than trade against it. # # It also issues a GRC20 and registers it with r/nt/grc20reg, which hands an # object to another realm: a private realm panics at runtime when it does that. # # What private would have bought, redeploy-in-place, is given up deliberately. # Every thread, every balance and every pin under this path belongs to the # people who wrote them, so a fix ships as a v1 beside it rather than as a # redeploy that silently wipes the lot.
  6. #6render.gno
  7. #7package threads import ( "strconv" "strings" "chain/runtime" "gno.land/p/moul/kit/store/v0" "gno.land/p/moul/kit/ui/v0" "gno.land/p/moul/md/v0" "gno.land/p/moul/realmpath/v0" th "gno.land/p/moul/x/social/threads/v0" ) // Render routes three views: the realm's own index, one page's threads, and // one thread. // // A page key contains slashes, so "page/<key>" is split on the prefix rather // than by path element: realmpath gives the parts, and rejoining them would // only be a way to get it wrong. func Render(path string) string { req := realmpath.Parse(path) switch { case strings.HasPrefix(req.Path, "page/"): return renderPage(strings.TrimPrefix(req.Path, "page/")) case strings.HasPrefix(req.Path, "thread/"): return renderThread(strings.TrimPrefix(req.Path, "thread/")) case req.Path == "": return renderIndex() default: return md.H1("Not found") + "No such view: " + ui.Inline(req.Path) } } func renderIndex() string { out := md.H1("Threads") out += "A discussion block any realm can embed, keyed on the page it appears on.\n\n" t := ui.NewTable("threads", "pages", "THREAD supply") t.Row(strconv.Itoa(board.Len()), strconv.Itoa(board.Pages()), strconv.FormatInt(points.Supply(), 10)) out += t.String() out += md.H2("Recent, across every page") out += listing(board.Recent(20), true) out += md.H2("The token") out += points.Render() out += md.H2("Embedding it") out += "Two lines in the host realm, and it stores nothing:\n\n" out += md.LanguageCodeBlock("go", `import "`+realmPath+`" func Render(path string) string { return body + threads.RenderBlock() }`) out += "The page key is the host realm's own path, read off the call. " + md.InlineCode("RenderBlockFor(page)") + " names one explicitly.\n" return out } func renderPage(page string) string { if !th.ValidPage(page) { return md.H1("Threads") + "Not a page key: " + ui.Inline(page) } out := md.H1("Threads on " + ui.Inline(page)) out += md.Link("the page itself", th.RealmURL(page)) + "\n\n" out += listing(board.List(page, runtime.ChainHeight(), 0), false) out += "\n" + ui.Action("💬 Post here", "Post", "page", page, "body", "") + "\n" return out } func renderThread(raw string) string { id, ok := store.ParseID(raw) if !ok { return md.H1("Threads") + "Not a thread id: " + ui.Inline(raw) } t, found := board.Get(id) if !found { return md.H1("Threads") + "No thread #" + id.String() } out := md.H1("Thread #" + id.String()) out += md.Blockquote(ui.Inline(t.Body)) + "\n" out += md.Italic("by "+ui.Addr(t.Author)+" · on "+ md.Link(t.Page, th.PageURL(realmPath, t.Page))+ " · "+strconv.Itoa(t.Repliers())+" earned") + "\n\n" if t.PinnedUntil > runtime.ChainHeight() { out += "📌 pinned until block " + strconv.FormatInt(t.PinnedUntil, 10) + "\n\n" } out += md.H2("Replies") if len(t.Replies) == 0 { out += ui.Empty("Nobody has replied yet.") } else { r := ui.NewTable("by", "said", "block") for _, rep := range t.Replies { r.Row(ui.Addr(rep.Author), ui.Cell(rep.Body), strconv.FormatInt(rep.At, 10)) } out += r.String() } out += "\n" + ui.Action("💬 Reply", "Reply", "threadID", id.String(), "body", "") + " · " + ui.Action("📌 Boost", "Boost", "threadID", id.String()) + "\n" return out } // listing renders a set of threads. withPage adds the page column, which the // index needs and a page view would only repeat. func listing(items []th.Listing, withPage bool) string { if len(items) == 0 { return ui.Empty("No threads yet.") } headers := []string{"", "#", "thread", "by", "replies"} if withPage { headers = append(headers, "page") } t := ui.NewTable(headers...) now := runtime.ChainHeight() for _, it := range items { mark := "" if it.Thread.Pinned(now) { mark = "📌" } cells := []string{ mark, // The link title is the id, never the body: md.Link escapes // markdown but not a pipe, and a pipe in a table cell opens a // column. ui.Cell is the only thing that rewrites it. md.Link("#"+it.ID.String(), th.ThreadURL(realmPath, it.ID)), ui.Cell(ui.ShortN(it.Thread.Body, th.ExcerptLen, 0)), ui.Addr(it.Thread.Author), strconv.Itoa(len(it.Thread.Replies)), } if withPage { cells = append(cells, ui.Cell(it.Thread.Page)) } t.Row(cells...) } return t.String() }
  8. #8threads.gno
  9. #9// Package threads is a discussion block any realm can embed, keyed on the page // it appears on rather than on the realm that stores it. // // It is the text half of gno.land/r/moul/reactions: the same embed shape, the // same page keys, two lines in the host realm. // // import "gno.land/r/moul/x/social/threads/v0" // // func Render(path string) string { // return body + threads.RenderBlock() // } // // [RenderBlock] takes no argument because it does not need one: a plain read // exported by a realm and called by another opens no realm frame, so // unsafe.CurrentRealm() inside it reports the CALLER. The host realm's own // path is the page key, and nothing has to be configured or registered. // [RenderBlockFor] is the explicit form, for a realm that renders more than // one page or already holds its path as a constant. // // # The token, and the three things it has to answer // // THREAD exists because placement is the one thing a discussion has that // people want and cannot all have at once. // // mint 1 THREAD to a thread's author, the first time each distinct // address replies to it. People, not messages, and never yourself. // sink [Boost] burns PinCost THREAD to pin a thread to the top of its // page for PinBlocks blocks. That is the only thing that removes // supply, and the only thing the token does. // buyer anyone who wants placement and has not earned it. They have to // acquire THREAD from someone who did, with [Transfer], which is // what makes attention a market rather than a scoreboard. // // It is farmable, and saying so is cheaper than pretending otherwise: two // addresses replying to each other mint one THREAD each per thread. The fix is // a gate on who counts as a replier, which belongs in a vouching realm and not // here; until then the cost of farming is one transaction per point and the // benefit is capped by what a pin is worth. package threads import ( "chain" "chain/runtime" "chain/runtime/unsafe" "strconv" "gno.land/p/moul/kit/store/v0" sc "gno.land/p/moul/x/social/coin/v0" th "gno.land/p/moul/x/social/threads/v0" "gno.land/r/nt/grc20reg/v0" ) // realmPath is this realm's own path, the one its gnomod.toml module line // declares. It is written out rather than read from unsafe.CurrentRealm(), // which reports the CALLER in every plain read this realm exports and would // therefore build every link against whichever realm embedded the block. const realmPath = "gno.land/r/moul/x/social/threads/v0" const ( // EarnPerReplier is minted to a thread's author the first time each // distinct address replies to it. EarnPerReplier = int64(1) // PinCost is burned by Boost, and PinBlocks is how long the pin lasts. // A pin extends from whichever is later, now or the current pin, so // buying one never shortens somebody else's. PinCost = int64(10) PinBlocks = int64(1000) // BlockThreads is how many threads an embedded block shows before it // turns into a link to the full page. BlockThreads = 5 ) var ( // board holds every thread. A redeploy would wipe it, which is why this // realm is not private (see gnomod.toml). board = th.NewBoard() // points is the THREAD ledger. The Coin is unexported on purpose: it // carries unrestricted mint and burn, and an exported pointer to it // would hand both to any importer. points *sc.Coin // tokenKey is the r/nt/grc20reg lookup key, which is how a realm that // does not import this one refers to THREAD. tokenKey string ) // tokenPolicy is the three declarations THREAD is issued under. It is a var // and not three literals inside init so that the tests, which rebuild the // ledger between cases, issue the same token the chain holds. var tokenPolicy = sc.Policy{ Mint: "1 to a thread's author per distinct address that replies to it", Sink: "burned by Boost to pin a thread to the top of its page", Buyer: "anyone who wants placement and has not earned it", } func init(cur realm) { points = sc.New("Thread Points", "THREAD", 0, 0, tokenPolicy, 0, cur) tokenKey = grc20reg.Register(cross(cur), points.Token(), "thread") } // Post opens a thread under page. // // page is a package path, the full one: "gno.land/r/moul/home". It does not // have to be a realm that embeds the block, and nothing checks that it exists. // A page is just a name, which is what lets a realm key discussion on // something finer than itself. func Post(cur realm, page, body string) int64 { who := caller(cur) id, err := board.Post(page, who, body, runtime.ChainHeight()) if err != nil { panic(err.Error()) } chain.Emit("Post", "id", id.String(), "page", page, "author", who.String()) return int64(id) } // Reply appends to a thread. // // The first time each distinct address replies to a given thread, its author // earns EarnPerReplier. Replying again earns nothing, and replying to your own // thread never earns anything. func Reply(cur realm, threadID int64, body string) { who := caller(cur) id := store.ID(threadID) isNew, err := board.Reply(id, who, body, runtime.ChainHeight()) if err != nil { panic(err.Error()) } chain.Emit("Reply", "id", id.String(), "author", who.String()) if isNew { t, _ := board.Get(id) points.Earn(t.Author, EarnPerReplier) chain.Emit("Earn", "id", id.String(), "author", t.Author.String()) } } // Boost burns PinCost THREAD from the caller and pins the thread to the top of // its page for PinBlocks blocks. // // Anyone may boost anyone's thread. The pin is on the thread and not on the // buyer, so the thing being sold is placement and not authorship. func Boost(cur realm, threadID int64) { who := caller(cur) id := store.ID(threadID) t, ok := board.Get(id) if !ok { panic(th.ErrNoThread.Error()) } now := runtime.ChainHeight() from := now if t.PinnedUntil > from { from = t.PinnedUntil } // Charge first, pin second. The chain would revert both on an abort, so // the order is invisible on mainnet and load-bearing everywhere else: a // test, a filetest and any future caller that recovers see a refused // boost that pinned nothing. points.Spend(who, PinCost) if err := board.Pin(id, from+PinBlocks); err != nil { panic(err.Error()) } chain.Emit("Boost", "id", id.String(), "by", who.String(), "until", strconv.FormatInt(from+PinBlocks, 10)) } // Transfer moves the caller's own THREAD. It is what makes the sink a market: // somebody who wants a pin and has written nothing has to buy from an author. func Transfer(cur realm, to address, amount int64) { if err := points.CallerTeller().Transfer(0, cur, to, amount); err != nil { panic(err.Error()) } } // Approve lets spender draw amount of the caller's THREAD. func Approve(cur realm, spender address, amount int64) { if err := points.CallerTeller().Approve(0, cur, spender, amount); err != nil { panic(err.Error()) } } // TransferFrom spends an allowance the caller was granted. func TransferFrom(cur realm, from, to address, amount int64) { if err := points.CallerTeller().TransferFrom(0, cur, from, to, amount); err != nil { panic(err.Error()) } } // RenderBlock returns the block for the realm CALLING it, which is the form a // host realm embeds. // // It has no cur realm parameter on purpose. A plain read is borrowed: gno // opens no realm frame for it, so unsafe.CurrentRealm() reports the caller's // path and the block keys itself with no argument. Adding a cur realm here // would silently reverse that and key every embed to this realm instead. func RenderBlock() string { return blockFor(unsafe.CurrentRealm().PkgPath()) } // RenderBlockFor is [RenderBlock] for a named page. Use it from a crossing // function, where there is no caller to read off the stack, or when one realm // renders several pages. func RenderBlockFor(page string) string { return blockFor(page) } // blockFor is the shared body, so no exported read of this realm calls another // and picks up the wrong path. func blockFor(page string) string { now := runtime.ChainHeight() return th.Block(realmPath, page, board.List(page, now, BlockThreads), board.PageLen(page), now) } // Count is how many threads a page holds. func Count(page string) int { return board.PageLen(page) } // Total is how many threads exist, across every page. func Total() int { return board.Len() } // Pages is how many pages have ever been posted on. func Pages() int { return board.Pages() } // Repliers is how many distinct addresses have replied to a thread, which is // also how much its author has earned from it. func Repliers(threadID int64) int { t, _ := board.Get(store.ID(threadID)) return t.Repliers() } // PinnedUntil is the height a thread stops being pinned at, or 0. func PinnedUntil(threadID int64) int64 { t, ok := board.Get(store.ID(threadID)) if !ok { return 0 } return t.PinnedUntil } // BalanceOf is an address's THREAD balance. func BalanceOf(owner address) int64 { return points.BalanceOf(owner) } // Allowance is what owner let spender draw. func Allowance(owner, spender address) int64 { return points.Token().Allowance(owner, spender) } // TotalSupply is the THREAD in existence: everything earned, less everything // burned by Boost. func TotalSupply() int64 { return points.Supply() } // TokenKey is the r/nt/grc20reg key for THREAD. func TokenKey() string { return tokenKey } // caller is the address that called us, checked the one way that is safe. func caller(cur realm) address { if !cur.IsCurrent() { panic("spoofed realm: cur is not the live crossing frame") } return cur.Previous().Address() }
  10. #10threads_test.gno
  11. #11package threads import ( "strconv" "strings" "testing" sc "gno.land/p/moul/x/social/coin/v0" th "gno.land/p/moul/x/social/threads/v0" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" "gno.land/p/nt/urequire/v0" ) const homePage = "gno.land/r/moul/home" var ( alice = testutils.TestAddress("alice") bob = testutils.TestAddress("bob") carol = testutils.TestAddress("carol") ) // reset clears realm state. Realm globals live for the whole test binary, so // everything a later test reads, including the ledger, starts here. // // The ledger is rebuilt through a same-realm cross because grc20.NewToken // needs a code-realm frame, which a test's EOA-origin cur is not. It is NOT // re-registered with grc20reg: the registry is another realm and its entry // survives this one's state. func reset(cur realm) { board = th.NewBoard() func(cur realm) { // the name is forced: a realm argument must be called cur points = sc.New("Thread Points", "THREAD", 0, 0, tokenPolicy, 0, cur) }(cross(cur)) } // earn gives who n THREAD the only way the realm allows: n distinct addresses // replying to a thread they wrote. func earn(cur realm, t *testing.T, who address, n int) int64 { t.Helper() testing.SetRealm(testing.NewUserRealm(who)) id := Post(cross(cur), homePage, "a thread by "+who.String()) for i := 0; i < n; i++ { testing.SetRealm(testing.NewUserRealm(testutils.TestAddress("replier" + strconv.Itoa(i)))) Reply(cross(cur), id, "hello") } return id } func TestAnAuthorEarnsPeopleAndNotMessages(cur realm, t *testing.T) { reset(cur) testing.SetRealm(testing.NewUserRealm(alice)) id := Post(cross(cur), homePage, "first thread") uassert.Equal(t, int64(0), BalanceOf(alice), "writing earns nothing on its own") testing.SetRealm(testing.NewUserRealm(bob)) Reply(cross(cur), id, "nice") uassert.Equal(t, int64(1), BalanceOf(alice)) testing.SetRealm(testing.NewUserRealm(bob)) Reply(cross(cur), id, "still me") uassert.Equal(t, int64(1), BalanceOf(alice), "one person is one point, however much they say") testing.SetRealm(testing.NewUserRealm(alice)) Reply(cross(cur), id, "thanks") uassert.Equal(t, int64(1), BalanceOf(alice), "replying to yourself is not an audience") testing.SetRealm(testing.NewUserRealm(carol)) Reply(cross(cur), id, "hi") uassert.Equal(t, int64(2), BalanceOf(alice)) uassert.Equal(t, 2, Repliers(id)) uassert.Equal(t, int64(2), TotalSupply()) uassert.Equal(t, int64(0), BalanceOf(bob), "a replier earns nothing") } func TestBoostIsTheOnlyThingThatBurns(cur realm, t *testing.T) { reset(cur) id := earn(cur, t, alice, 10) urequire.Equal(t, int64(10), BalanceOf(alice)) testing.SetRealm(testing.NewUserRealm(alice)) Boost(cross(cur), id) uassert.Equal(t, int64(0), BalanceOf(alice)) uassert.Equal(t, int64(0), TotalSupply(), "the sink removes supply, it does not move it") uassert.Equal(t, int64(123+PinBlocks), PinnedUntil(id), "tests start at height 123") } func TestBoostRefusesWhatTheCallerCannotAfford(cur realm, t *testing.T) { reset(cur) id := earn(cur, t, alice, 1) testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "balance 1 is short of 10", func() { Boost(cross(cur), id) }) uassert.Equal(t, int64(0), PinnedUntil(id), "a refused boost pins nothing") uassert.Equal(t, int64(1), BalanceOf(alice)) } // A second boost extends from the current pin, not from now, so the thread // never loses time it was already paid for. func TestASecondBoostExtendsRatherThanRestarts(cur realm, t *testing.T) { reset(cur) id := earn(cur, t, alice, 20) testing.SetRealm(testing.NewUserRealm(alice)) Boost(cross(cur), id) first := PinnedUntil(id) testing.SetRealm(testing.NewUserRealm(alice)) Boost(cross(cur), id) uassert.Equal(t, first+PinBlocks, PinnedUntil(id)) uassert.Equal(t, int64(0), BalanceOf(alice)) } // Anyone may boost anyone's thread, and the buyer has to get THREAD from // somebody who earned it. That is the whole market. func TestPlacementIsBoughtFromAnEarner(cur realm, t *testing.T) { reset(cur) id := earn(cur, t, alice, 10) testing.SetRealm(testing.NewUserRealm(alice)) Transfer(cross(cur), bob, 10) uassert.Equal(t, int64(0), BalanceOf(alice)) uassert.Equal(t, int64(10), BalanceOf(bob)) testing.SetRealm(testing.NewUserRealm(bob)) Boost(cross(cur), id) uassert.True(t, PinnedUntil(id) > 0, "bob pinned alice's thread") uassert.Equal(t, int64(0), TotalSupply()) } func TestPostRefusals(cur realm, t *testing.T) { reset(cur) testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "not a page key", func() { Post(cross(cur), "nopath", "body") }) testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "body is empty", func() { Post(cross(cur), homePage, " ") }) testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "no such thread", func() { Reply(cross(cur), 99, "into the void") }) uassert.Equal(t, 0, Total()) } func TestCountsAreScopedToTheirPage(cur realm, t *testing.T) { reset(cur) testing.SetRealm(testing.NewUserRealm(alice)) Post(cross(cur), homePage, "on home") testing.SetRealm(testing.NewUserRealm(alice)) Post(cross(cur), "gno.land/r/moul/blog", "on the blog") uassert.Equal(t, 1, Count(homePage)) uassert.Equal(t, 0, Count("gno.land/r/nobody/here")) uassert.Equal(t, 2, Total()) uassert.Equal(t, 2, Pages()) } // RenderBlockFor is the embed, and it escapes what it shows: a raw pipe in a // body would otherwise open a column in the host realm's page. func TestRenderBlockForEscapesTheBody(cur realm, t *testing.T) { reset(cur) testing.SetRealm(testing.NewUserRealm(alice)) Post(cross(cur), homePage, "a | pipe") got := RenderBlockFor(homePage) uassert.True(t, strings.Contains(got, "func=Post"), "the block offers the transaction: "+got) uassert.True(t, strings.Contains(got, `| a \| pipe |`), "the body cell escapes the pipe rather than opening a column: "+got) uassert.False(t, strings.Contains(got, "| a | pipe"), "no raw pipe survives: "+got) uassert.True(t, strings.Contains(RenderBlockFor("gno.land/r/nobody/here"), "No discussion yet.")) } // TestRender uses uassert rather than an Example because every view contains // consecutive blank lines, which gno collapses inside an // Output: block. func TestRender(cur realm, t *testing.T) { reset(cur) testing.SetRealm(testing.NewUserRealm(alice)) id := Post(cross(cur), homePage, "a thread about [links](x)") testing.SetRealm(testing.NewUserRealm(bob)) Reply(cross(cur), id, "a reply with a | pipe") index := Render("") uassert.True(t, strings.Contains(index, "| 1 | 1 | 1 |"), "threads, pages, supply: "+index) uassert.True(t, strings.Contains(index, tokenPolicy.Sink), "the index declares the sink") uassert.True(t, strings.Contains(index, `import "`+realmPath+`"`), "the index is the embed doc") page := Render("page/" + homePage) uassert.True(t, strings.Contains(page, "/r/moul/home"), "it links the page itself: "+page) uassert.True(t, strings.Contains(page, "func=Post")) sid := strconv.FormatInt(id, 10) thread := Render("thread/" + sid) uassert.True(t, strings.Contains(thread, "# Thread #"+sid)) uassert.True(t, strings.Contains(thread, "a thread about \\[links\\]\\(x\\)"), "the body is escaped where it is shown: "+thread) uassert.True(t, strings.Contains(thread, "a reply with a \\| pipe"), "a reply cell cannot open a column: "+thread) uassert.True(t, strings.Contains(thread, "func=Boost")) uassert.Equal(t, "# Threads\nNo thread #4242", Render("thread/4242")) uassert.Equal(t, "# Threads\nNot a thread id: nope", Render("thread/nope")) uassert.Equal(t, "# Threads\nNot a page key: nope", Render("page/nope")) uassert.True(t, strings.HasPrefix(Render("elsewhere"), "# Not found")) } func TestTokenIsDiscoverableWithoutImportingThisRealm(t *testing.T) { uassert.True(t, strings.HasSuffix(TokenKey(), ".THREAD"), TokenKey()) uassert.True(t, strings.Contains(TokenKey(), realmPath), TokenKey()) }
#8AddPackagegno.land/r/moul/x/social/vouch/v011 arguments
Attached funds
13000000ugnot

Arguments · 11

  1. #1vouch
  2. #2README.md
  3. #3# `gno.land/r/moul/x/social/vouch/v0` **A web of trust other realms gate on.** One address vouches for another, in writing, optionally with GNOT locked behind it. Any realm can then ask whether an address is vouched for by at least N distinct people and refuse to serve it if it is not. ```go import "gno.land/r/moul/x/social/vouch/v0" func Claim(cur realm) { if !vouch.IsTrusted(cur.Previous().Address(), 2) { panic("get two people to vouch for you first") } ... } ``` That is the whole product. It exists because the apps beside it do not have a sybil gate: a realm that mints a point per distinct replier is farmed by two addresses replying to each other, and a realm that counts one vote per address is farmed by holding a hundred addresses. Neither can fix it alone, because neither knows anything about the people behind the addresses. ## The API | write | | |---|---| | `Vouch(target, reason)` | payable. Records the caller's vouch. Coins sent are locked as a bond on it. Vouching again updates the reason and adds to the bond | | `Revoke(target)` | removes the caller's vouch and credits the bond back to them | | `Withdraw()` | pays the caller every bond their revocations freed | | read | | |---|---| | `IsTrusted(addr, min)` | **the gate.** At least `min` distinct vouchers, `min` below one read as one | | `ScoreOf(addr)` | distinct inbound vouchers | | `BondedFor(addr)` | total ugnot bonded on them | | `VouchedBy(addr)` / `VouchesOf(addr)` | the two directions, sorted | | `Mutual(a, b)` | whether they vouch for each other | | `ReasonFrom(from, to)` / `BondFrom(from, to)` | one edge | | `Count`, `People`, `Owed`, `TotalBonded`, `TotalOwed` | the totals | | `Badge(addr)` | the one-line trust mark, for a host realm's own page | The engine is `p/moul/x/social/vouch/v0`, which holds the graph, the validation and the refund ledger and takes the height, the caller and the bond as arguments. This realm is the chain wiring and the `Render`. ## Three traps it avoids **A score counts people, not vouches.** A second vouch from the same address to the same target is an edit: the reason is replaced, the bond is added to, and the score does not move. Without that, the gate measures transactions, and transactions are for sale. **It never loops and sends.** `Revoke` credits a ledger and `Withdraw` pays one payee, zeroing the credit before the coins leave. A realm that sent on revoke would hand control to the recipient mid-transition, and a recipient that refuses coins could make revoking impossible. **A realm cannot forward a bond.** The payment envelope belongs to the transaction, not to the frame, so a realm that was itself paid and then calls `Vouch` would report coins sitting at its own address. This realm refuses that call outright rather than quietly reading the bond as zero. A realm may vouch; it may not vouch while holding somebody else's payment. ## No slashing in v0, and that is the hard part A bond is value at risk only in the sense that it is illiquid: it can be recovered by revoking, and nothing can take it away. That is not an oversight, and the missing half is not the accounting. Slashing needs an arbiter, somebody who decides that a vouch was a lie. A DAO vote is a popularity contest against whoever is unpopular this month. A challenge market pays whoever is loudest and turns the graph into a griefing surface. An oracle is one key that can confiscate anybody's money. Shipping any of them by default would be shipping the wrong one, so v0 ships the part that is uncontroversial: who said what, who put money behind it, and the gate that reads it. The bond still earns its place without slashing. An illiquid deposit is a cost a hundred throwaway addresses cannot all pay at once, which is exactly the shape the gate is defending against. ## And can it have a token? **No, and the reason is the point.** Every app in this family is asked the same question, and here the honest answer is no. A transferable vouch is a bought reputation: the moment a vouch can be sold, the score stops measuring what it claims to measure, and what it measures instead is who had the most money this week. That is the one failure mode a sybil gate cannot survive, because it is not a degradation, it is the attack. The bond is GNOT. It is value at risk without being a market in trust itself: you can lock your own money behind a claim, and you cannot sell the claim. What would change the answer is a token whose **only** use is being locked and slashed, with no transfer path that buys standing, and that needs an arbiter first, because without something that can slash it the token is a scoreboard with a price. If that day comes, the shape to issue it under is `p/moul/x/social/coin/v0`, a sibling package: a GRC20 that refuses to exist until its mint rule, its sink and its buyer are written down. It is not imported here, deliberately. Saying no with a reason is the deliverable. ## Public, not private Realms in this family default to `private = true`, which buys redeploy in place. This one is public and its `gnomod.toml` argues why: a private realm cannot be imported, and a gate that can only be read over the chain is not a gate, since a realm cannot pause mid-transaction to query itself. The second reason settles it: the graph and the refund ledger are package-level variables while the bonded ugnot sits at the realm's address, so a redeploy would wipe the record of whose money that is and keep the money. A realm holding somebody else's coins should not be able to forget who they belong to. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **On mainnet:** [![deployment status](https://gnoscope.com/_badges/shield/status/r/moul/x/social/vouch/v0?network=mainnet)](https://gnoscope.com/realm/r/moul/x/social/vouch/v0) [![transactions](https://gnoscope.com/_badges/shield/txs/r/moul/x/social/vouch/v0?network=mainnet)](https://gnoscope.com/realm/r/moul/x/social/vouch/v0) [![unique callers](https://gnoscope.com/_badges/shield/users/r/moul/x/social/vouch/v0?network=mainnet)](https://gnoscope.com/realm/r/moul/x/social/vouch/v0) [![deployed revision](https://gnoscope.com/_badges/shield/version/r/moul/x/social/vouch/v0?network=mainnet)](https://gnoscope.com/realm/r/moul/x/social/vouch/v0) **Dependency graph:** ![gno.land/r/moul/x/social/vouch/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/x/social/vouch/v0/deps.png) > 🧪 **Highly experimental — potentially vibe-coded.** Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
  4. #4gnomod.toml
  5. #5module = "gno.land/r/moul/x/social/vouch/v0" gno = "0.9" # public: other realms are expected to IMPORT IsTrusted and call it inside # their own crossing functions. A private realm can still be READ from outside # (vm/qrender and vm/qeval are unaffected by private), but a gate that only # answers over the chain is not a gate: a realm cannot pause mid-transaction to # query itself, so gating a mint or a vote on this graph requires the import. # Making it private would leave the product intact for humans and remove it for # the callers it was written for. # # The second reason is the money, and it is the one that settles it. Every # vouch, every bond and the ledger that says who is owed a refund are # package-level variables, while the ugnot backing them sits at this realm's # address. A redeploy wipes the first and keeps the second, so the convenience # private buys would, the one time it were used, strand other people's bonds # with no record of whose they were. A realm holding somebody else's coins # should not be able to forget who they belong to. # # What private would have bought, redeploy-in-place, is given up deliberately. # The vouch graph belongs to the people who wrote it, not to whoever deployed # this path, so a fix ships as a v1 beside it rather than as a redeploy that # silently erases the lot.
  6. #6render.gno
  7. #7package vouch import ( "strconv" "gno.land/p/moul/kit/num/v0" "gno.land/p/moul/kit/ui/v0" "gno.land/p/moul/md/v0" "gno.land/p/moul/realmpath/v0" vo "gno.land/p/moul/x/social/vouch/v0" ) // Render routes two views: the realm's own index, and one address's page. // // An address carries no slash, so the per-address route is a path element and // not a prefix strip. func Render(path string) string { req := realmpath.Parse(path) switch req.PathPart(0) { case "": return renderIndex() case "addr": return renderAddr(req.PathPart(1)) default: return md.H1("Not found") + "No such view: " + ui.Inline(req.Path) } } func renderIndex() string { out := md.H1("Vouch") out += "A web of trust other realms gate on. One address vouches for another, " + "in writing, optionally with GNOT locked behind it.\n\n" t := ui.NewTable("vouches", "people vouched for", "bonded", "awaiting withdrawal") t.Row( strconv.Itoa(graph.Count()), strconv.Itoa(graph.People()), num.GNOTf(graph.TotalBonded()), num.GNOTf(graph.TotalOwed()), ) out += t.String() out += md.H2("Most vouched for") out += board() out += md.H2("The gate") out += "One line in the realm that wants it, and it costs no transaction:\n\n" out += md.LanguageCodeBlock("go", `import "`+realmPath+`" if !vouch.IsTrusted(addr, 2) { panic("get two people to vouch for you first") }`) out += md.Italic("A score counts distinct vouchers, so restating a vouch does not " + "raise it and revoking lowers it. It does not count distinct people: a pair " + "vouching for each other both reach one, which is why a real gate asks for " + "more.") + "\n\n" out += md.H2("Take part") out += ui.Action("\U0001F91D Vouch", "Vouch", "target", "", "reason", "") + " · " + ui.Action("\U0001F4B0 Withdraw a refunded bond", "Withdraw") + "\n\n" out += md.Italic("Coins sent with Vouch are locked as a bond on that vouch and come " + "back when you revoke it. There is no slashing: deciding that a vouch was a lie " + "needs an arbiter, and choosing one is the whole design question.") + "\n" return out } func board() string { rows := graph.Leaderboard(BoardSize) if len(rows) == 0 { return ui.Empty("Nobody has been vouched for yet.") } t := ui.NewTable("", "address", "vouchers", "bonded") for i, r := range rows { t.Row( ui.Podium(i), md.Link(ui.AddrText(r.Addr), vo.AddrURL(realmPath, r.Addr)), strconv.Itoa(r.Score), num.GNOTf(r.Bonded), ) } return t.String() } func renderAddr(raw string) string { addr := address(raw) if !addr.IsValid() { return md.H1("Vouch") + "Not an address: " + ui.Inline(raw) } out := md.H1("Vouch for " + ui.AddrText(addr)) out += ui.AddrFull(addr) + "\n\n" score := graph.ScoreOf(addr) t := ui.NewTable("vouchers", "bonded on them", "trusted at 2", "awaiting withdrawal") t.Row( strconv.Itoa(score), num.GNOTf(graph.BondedFor(addr)), yesno(graph.IsTrusted(addr, 2)), num.GNOTf(graph.Owed(addr)), ) out += t.String() out += md.H2("Vouched for by") out += inbound(addr) out += md.H2("Vouches for") out += outbound(addr) out += "\n" + ui.Action("\U0001F91D Vouch for this address", "Vouch", "target", addr.String(), "reason", "") + " · " + ui.Action("\U0000274C Revoke mine", "Revoke", "target", addr.String()) + "\n" return out } func inbound(addr address) string { from := graph.VouchedBy(addr) if len(from) == 0 { return ui.Empty("Nobody vouches for this address.") } t := ui.NewTable("voucher", "reason", "bond", "mutual", "since") for _, f := range from { v, ok := graph.Get(f, addr) if !ok { continue } t.Row( md.Link(ui.AddrText(f), vo.AddrURL(realmPath, f)), ui.Cell(v.Reason), num.GNOTf(v.Bond), yesno(graph.Mutual(f, addr)), strconv.FormatInt(v.At, 10), ) } return t.String() } func outbound(addr address) string { to := graph.VouchesOf(addr) if len(to) == 0 { return ui.Empty("This address vouches for nobody.") } t := ui.NewTable("for", "reason", "bond", "their vouchers") for _, target := range to { v, ok := graph.Get(addr, target) if !ok { continue } t.Row( md.Link(ui.AddrText(target), vo.AddrURL(realmPath, target)), ui.Cell(v.Reason), num.GNOTf(v.Bond), strconv.Itoa(graph.ScoreOf(target)), ) } return t.String() } func yesno(b bool) string { if b { return "yes" } return "no" }
  8. #8vouch.gno
  9. #9// Package vouch is a web of trust other realms can gate on: one address // vouches for another, in writing, optionally with GNOT locked behind it. // // The one call that matters is a plain read: // // import "gno.land/r/moul/x/social/vouch/v0" // // func Claim(cur realm) { // if !vouch.IsTrusted(cur.Previous().Address(), 2) { // panic("get two people to vouch for you first") // } // … // } // // [IsTrusted] takes no cur realm on purpose. A read with no realm token is // borrowed: gno opens no realm frame for it, so it costs the caller nothing // beyond the read and cannot be tricked into acting as anybody. Nothing here // branches on who is asking, and no read in this realm ever will: the answer // to "is this address trusted" must not depend on who wants to know. // // # What it does, and what it refuses to do // // [Vouch] is payable. Any coins sent with it are locked as a bond on that // vouch, which is the voucher putting their own money where their claim is. A // bond is optional and zero is the ordinary case. [Revoke] takes the vouch // back and credits the bond to the voucher, who collects it with [Withdraw]. // // There is no slashing in v0 and that is the interesting half. Slashing needs // an arbiter, somebody who decides a vouch was a lie, and every candidate is // a design question rather than a feature: a DAO vote is a popularity contest, // a challenge market pays whoever is loudest, an oracle is one key that can // confiscate anybody's money. The bond is still worth having without it, // because an illiquid deposit is a cost a hundred throwaway addresses cannot // all pay at once. // // # No token // // This realm issues none, deliberately. A transferable vouch is a bought // reputation, and the moment a vouch can be sold the score stops measuring // what it says it measures. The bond is GNOT: value at risk, without being a // market in trust itself. The README says what would change the answer. package vouch import ( "chain" "chain/banker" "chain/runtime" "strconv" "gno.land/p/moul/x/envelope/v0" vo "gno.land/p/moul/x/social/vouch/v0" ) // realmPath is this realm's own path, the one its gnomod.toml module line // declares. It is written out rather than read from the frame, because every // read this realm exports is borrowed and would report whichever realm called // it, building every link against somebody else's page. const realmPath = "gno.land/r/moul/x/social/vouch/v0" // denom is the only coin a bond can be posted in. const denom = "ugnot" // BoardSize is how many addresses the index page ranks. const BoardSize = 10 // graph holds every vouch and the refund ledger. A redeploy would wipe it // while leaving the bonded coins at this address, which is why this realm is // not private (see gnomod.toml). var graph = vo.NewGraph() // Vouch records that the caller stands behind target, for the stated reason. // // It is payable: coins sent with the call are locked as a bond on this vouch // and are returned by [Revoke], never by anything else. Sending nothing is // fine and is the ordinary case. // // Vouching again for the same address UPDATES the reason and ADDS to the // bond. It does not count twice: the score this realm exists to publish is a // count of people, so a second transaction from the same address buys // precisely nothing. Vouching for yourself is refused. func Vouch(cur realm, target address, reason string) { who, userCall := caller(cur) bond := bondOf(userCall) updated, err := graph.Record(who, target, reason, bond, runtime.ChainHeight()) if err != nil { panic(err.Error()) } event := "Vouch" if updated { event = "Revouch" } chain.Emit(event, "from", who.String(), "for", target.String(), "bond", strconv.FormatInt(bond, 10)) } // Revoke withdraws the caller's vouch for target and credits any bond back to // them. The coins do not move here: call [Withdraw] to collect them. // // Splitting it in two is the pull-payment pattern. A realm that sent on revoke // would be handing control to the recipient in the middle of its own state // change, and a recipient that refuses the coins could make revoking // impossible. func Revoke(cur realm, target address) int64 { who, _ := caller(cur) refund, err := graph.Revoke(who, target) if err != nil { panic(err.Error()) } chain.Emit("Revoke", "from", who.String(), "for", target.String(), "refund", strconv.FormatInt(refund, 10)) return refund } // Withdraw pays the caller every bond their revoked vouches freed, and // returns the amount sent. // // The credit is zeroed before the coins leave, so a recipient that calls // straight back in finds nothing to take. func Withdraw(cur realm) int64 { who, _ := caller(cur) amount, err := graph.Withdraw(who) if err != nil { panic(err.Error()) } bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur) bnk.SendCoins(cur.Address(), who, chain.NewCoins(chain.NewCoin(denom, amount))) chain.Emit("Withdraw", "to", who.String(), "amount", strconv.FormatInt(amount, 10)) return amount } // IsTrusted reports whether addr is vouched for by at least min distinct // addresses. This is the gate, and it is one line at the call site. // // A min below one is read as one: a gate that lets everybody through is a bug // in the caller rather than an answer this realm will agree to. // // It cannot tell you that the vouchers are distinct people. Two addresses // vouching for each other both reach a score of one, which [Mutual] exposes // and which is the reason to ask for more than one. func IsTrusted(addr address, min int) bool { return graph.IsTrusted(addr, min) } // ScoreOf is how many distinct addresses vouch for addr. func ScoreOf(addr address) int { return graph.ScoreOf(addr) } // BondedFor is the total ugnot locked on addr by everyone vouching for them. func BondedFor(addr address) int64 { return graph.BondedFor(addr) } // VouchedBy is every address that vouches for addr, sorted. func VouchedBy(addr address) []address { return graph.VouchedBy(addr) } // VouchesOf is every address addr vouches for, sorted. func VouchesOf(addr address) []address { return graph.VouchesOf(addr) } // Mutual reports whether a and b vouch for each other, which is the cheapest // sybil shape and therefore worth discounting. func Mutual(a, b address) bool { return graph.Mutual(a, b) } // ReasonFrom is what from wrote about target, or the empty string. It is raw // caller text: escape it before rendering it anywhere. func ReasonFrom(from, target address) string { return graph.ReasonFrom(from, target) } // BondFrom is what from locked on target, or zero. func BondFrom(from, target address) int64 { return graph.BondFrom(from, target) } // Count is how many vouches exist. func Count() int { return graph.Count() } // People is how many addresses have at least one vouch for them. func People() int { return graph.People() } // Owed is what addr can collect with [Withdraw]. func Owed(addr address) int64 { return graph.Owed(addr) } // TotalBonded is everything locked on vouches that still stand. func TotalBonded() int64 { return graph.TotalBonded() } // TotalOwed is every revoked bond nobody has collected yet. This realm holds // TotalBonded plus TotalOwed on behalf of other people. func TotalOwed() int64 { return graph.TotalOwed() } // Badge is the one-line trust mark for addr, for a host realm that wants to // show what its gate just read. Like [IsTrusted] it is a borrowed read and // takes no realm token. func Badge(addr address) string { return vo.Badge(realmPath, addr, graph.ScoreOf(addr), graph.BondedFor(addr)) } // bondOf reads the coins attached to this call, and only when the chain // credited them to THIS realm. // // The envelope is the transaction's, not the frame's: a realm that was itself // paid and then calls here would report coins sitting at its own address, and // crediting them would let it bond money this realm never received. A realm // may vouch, with no bond; a realm may not vouch while holding somebody's // payment, and saying so loudly beats silently reading the bond as zero. // // It takes the answer rather than the frame because a realm argument in this // package must be named cur, which would make this a crossing function. func bondOf(userCall bool) int64 { if userCall { return envelope.Amount(denom) } if !envelope.IsEmpty() { panic("a realm cannot forward a bond: those coins were credited to the realm the user paid, not to this one") } return 0 } // caller is the address that called us, checked the one way that is safe, // plus whether it reached us as a direct user transaction. // // Both come from here so the realm reads cur.Previous() in exactly one place, // the one guarded by cur.IsCurrent(). func caller(cur realm) (who address, userCall bool) { if !cur.IsCurrent() { panic("spoofed realm: cur is not the live crossing frame") } prev := cur.Previous() return prev.Address(), prev.IsUserCall() }
  10. #10vouch_test.gno
  11. #11package vouch import ( "strings" "testing" "chain" "chain/banker" vo "gno.land/p/moul/x/social/vouch/v0" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) var ( alice = testutils.TestAddress("alice") bob = testutils.TestAddress("bob") carol = testutils.TestAddress("carol") realmAddr = chain.PackageAddress(realmPath) ) // reset clears realm state. Realm globals live for the whole test binary, so // every test that reads a score, a bond or a refund starts here. func reset() { graph = vo.NewGraph() } // fund simulates the coins attached to the next call: the runtime credits the // realm's address first, and the crossing function then reads the envelope off // the transaction. fund(0) clears it, which is the ordinary unbonded case. // // testing.SetRealm is call-frame scoped, so the caller still switches account // inline in the test body right before crossing. func fund(amount int64) { if amount <= 0 { testing.SetOriginSend(chain.Coins{}) return } testing.IssueCoins(realmAddr, chain.Coins{{denom, amount}}) testing.SetOriginSend(chain.Coins{{denom, amount}}) } func balanceOf(addr address) int64 { return banker.NewReadonlyBanker().GetCoins(addr).AmountOf(denom) } // The score counts people, which is the property every caller of IsTrusted // depends on: a repeated vouch does not raise it, a revoke lowers it. func TestScoreCountsPeopleAndNotVouches(cur realm, t *testing.T) { reset() fund(0) testing.SetRealm(testing.NewUserRealm(bob)) Vouch(cross(cur), alice, "worked with them for two years") uassert.Equal(t, 1, ScoreOf(alice)) fund(0) testing.SetRealm(testing.NewUserRealm(bob)) Vouch(cross(cur), alice, "still true") uassert.Equal(t, 1, ScoreOf(alice), "one person is one point, however often they say it") uassert.Equal(t, "still true", ReasonFrom(bob, alice), "the reason was updated") uassert.Equal(t, 1, Count()) fund(0) testing.SetRealm(testing.NewUserRealm(carol)) Vouch(cross(cur), alice, "we shipped together") uassert.Equal(t, 2, ScoreOf(alice)) uassert.Equal(t, 1, People(), "one address has been vouched for") fund(0) testing.SetRealm(testing.NewUserRealm(carol)) Revoke(cross(cur), alice) uassert.Equal(t, 1, ScoreOf(alice), "revoking lowers the score") uassert.Equal(t, 1, Count()) } func TestVouchRefusals(cur realm, t *testing.T) { reset() fund(0) testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "cannot vouch for itself", func() { Vouch(cross(cur), alice, "I am great") }) fund(0) testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "reason is empty", func() { Vouch(cross(cur), bob, " ") }) fund(0) testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "reason is empty", func() { Vouch(cross(cur), bob, "one line\nonly") }) fund(0) testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "no such vouch", func() { Revoke(cross(cur), bob) }) fund(0) testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "nothing to withdraw", func() { Withdraw(cross(cur)) }) uassert.Equal(t, 0, Count()) } // The bond follows the vouch in both directions, and the coins only move on // Withdraw. Balances are asserted here because the bank does not carry over // between test functions. func TestTheBondIsLockedUntilTheVouchIsRevoked(cur realm, t *testing.T) { reset() before := balanceOf(bob) fund(1_000_000) testing.SetRealm(testing.NewUserRealm(bob)) Vouch(cross(cur), alice, "1 GNOT says this is a real person") uassert.Equal(t, int64(1_000_000), BondedFor(alice)) uassert.Equal(t, int64(1_000_000), TotalBonded()) uassert.Equal(t, int64(0), Owed(bob), "nothing is owed while the vouch stands") // Vouching again adds to the bond rather than replacing it. fund(500_000) testing.SetRealm(testing.NewUserRealm(bob)) Vouch(cross(cur), alice, "doubling down") uassert.Equal(t, int64(1_500_000), BondedFor(alice)) uassert.Equal(t, int64(1_500_000), BondFrom(bob, alice)) // Revoking moves the bond to the ledger and nothing else. fund(0) testing.SetRealm(testing.NewUserRealm(bob)) refund := Revoke(cross(cur), alice) uassert.Equal(t, int64(1_500_000), refund) uassert.Equal(t, int64(0), BondedFor(alice)) uassert.Equal(t, int64(1_500_000), Owed(bob)) uassert.Equal(t, int64(1_500_000), TotalOwed()) uassert.Equal(t, before, balanceOf(bob), "revoking sends nothing") // Withdrawing is what moves the coins, once. fund(0) testing.SetRealm(testing.NewUserRealm(bob)) got := Withdraw(cross(cur)) uassert.Equal(t, int64(1_500_000), got) uassert.Equal(t, before+1_500_000, balanceOf(bob), "the bond came back") uassert.Equal(t, int64(0), Owed(bob)) uassert.Equal(t, int64(0), TotalOwed()) fund(0) testing.SetRealm(testing.NewUserRealm(bob)) uassert.AbortsContains(t, cur, "nothing to withdraw", func() { Withdraw(cross(cur)) }) uassert.Equal(t, before+1_500_000, balanceOf(bob), "and only once") } // A bond belongs to whoever posted it, not to whoever it was posted on. func TestOnlyTheVoucherGetsTheBondBack(cur realm, t *testing.T) { reset() fund(700_000) testing.SetRealm(testing.NewUserRealm(bob)) Vouch(cross(cur), alice, "my money, my claim") fund(0) testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "no such vouch", func() { Revoke(cross(cur), bob) }) fund(0) testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "nothing to withdraw", func() { Withdraw(cross(cur)) }) uassert.Equal(t, int64(700_000), BondedFor(alice), "the bond is untouched") } func TestIsTrustedIsTheGate(cur realm, t *testing.T) { reset() uassert.False(t, IsTrusted(alice, 1), "nobody starts trusted") uassert.False(t, IsTrusted(alice, 0), "a zero min does not open the gate for nobody") fund(0) testing.SetRealm(testing.NewUserRealm(bob)) Vouch(cross(cur), alice, "one") uassert.True(t, IsTrusted(alice, 1)) uassert.False(t, IsTrusted(alice, 2)) fund(0) testing.SetRealm(testing.NewUserRealm(carol)) Vouch(cross(cur), alice, "two") uassert.True(t, IsTrusted(alice, 2)) uassert.False(t, IsTrusted(alice, 3)) // The cheapest sybil shape, and the reason a real gate asks for two. fund(0) testing.SetRealm(testing.NewUserRealm(alice)) Vouch(cross(cur), bob, "back at you") uassert.True(t, Mutual(alice, bob)) uassert.True(t, IsTrusted(bob, 1), "a mutual pair passes a gate set at one") uassert.False(t, IsTrusted(bob, 2)) } func TestTheTwoDirections(cur realm, t *testing.T) { reset() fund(0) testing.SetRealm(testing.NewUserRealm(alice)) Vouch(cross(cur), bob, "a to b") fund(0) testing.SetRealm(testing.NewUserRealm(alice)) Vouch(cross(cur), carol, "a to c") fund(0) testing.SetRealm(testing.NewUserRealm(bob)) Vouch(cross(cur), carol, "b to c") uassert.Equal(t, 2, len(VouchesOf(alice))) uassert.Equal(t, 0, len(VouchedBy(alice))) uassert.Equal(t, 2, len(VouchedBy(carol))) uassert.Equal(t, 0, len(VouchesOf(carol))) uassert.Equal(t, "b to c", ReasonFrom(bob, carol)) uassert.Equal(t, "", ReasonFrom(carol, bob)) uassert.Equal(t, int64(0), BondFrom(alice, bob), "an unbonded vouch is worth nothing") } // Badge is a borrowed read: a host realm calls it with no realm token and // gets a line pointing back at this realm's page. func TestBadge(cur realm, t *testing.T) { reset() uassert.True(t, strings.Contains(Badge(alice), "not vouched for")) fund(2_000_000) testing.SetRealm(testing.NewUserRealm(bob)) Vouch(cross(cur), alice, "one") got := Badge(alice) uassert.True(t, strings.Contains(got, "1 voucher]"), got) uassert.True(t, strings.Contains(got, "2 GNOT bonded"), got) uassert.True(t, strings.Contains(got, "/r/moul/x/social/vouch/v0:addr/"+alice.String()), got) } // TestRender uses uassert rather than an Example because every view contains // consecutive blank lines, which gno collapses inside an // Output: block. func TestRender(cur realm, t *testing.T) { reset() fund(3_000_000) testing.SetRealm(testing.NewUserRealm(bob)) Vouch(cross(cur), alice, "a reason with [a link](x) and a | pipe") fund(0) testing.SetRealm(testing.NewUserRealm(carol)) Vouch(cross(cur), alice, "second voucher") index := Render("") uassert.True(t, strings.Contains(index, "| 2 | 1 | 3 GNOT | 0 GNOT |"), "vouches, people, bonded, owed: "+index) uassert.True(t, strings.Contains(index, `import "`+realmPath+`"`), "the index is the gate doc") uassert.True(t, strings.Contains(index, "func=Vouch"), "the index offers the transaction") uassert.True(t, strings.Contains(index, "\U0001F947"), "the board has a podium: "+index) page := Render("addr/" + alice.String()) uassert.True(t, strings.Contains(page, alice.String()), "the page names the address") uassert.True(t, strings.Contains(page, "a reason with \\[a link\\]\\(x\\) and a \\| pipe"), "the reason is escaped where it is shown: "+page) uassert.True(t, strings.Contains(page, "func=Revoke")) // The other direction of the same edge, from the voucher's page. vouchers := Render("addr/" + bob.String()) uassert.True(t, strings.Contains(vouchers, "This address vouches for nobody.") == false, "bob vouches for alice: "+vouchers) uassert.True(t, strings.Contains(vouchers, "Nobody vouches for this address.")) uassert.Equal(t, "# Vouch\nNot an address: nope", Render("addr/nope")) uassert.True(t, strings.HasPrefix(Render("elsewhere"), "# Not found")) // An empty graph says so rather than rendering an empty table. reset() uassert.True(t, strings.Contains(Render(""), "Nobody has been vouched for yet.")) } func TestRenderOfAnUnknownAddress(cur realm, t *testing.T) { reset() page := Render("addr/" + carol.String()) uassert.True(t, strings.Contains(page, "Nobody vouches for this address."), page) uassert.True(t, strings.Contains(page, "This address vouches for nobody."), page) uassert.True(t, strings.Contains(page, "| 0 | 0 GNOT | no | 0 GNOT |"), page) }

Result log

msg:0,success:true,log:,events:[]
msg:1,success:true,log:,events:[]
msg:2,success:true,log:,events:[]
msg:3,success:true,log:,events:[]
msg:4,success:true,log:,events:[]
msg:5,success:true,log:,events:[]
msg:6,success:true,log:,events:[]
msg:7,success:true,log:,events:[]

← Back to block 592,216