Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

vouch.gno

15.35 Kb · 462 lines
  1// Package vouch is the engine behind a web of trust: one address says it
  2// stands behind another, in writing, optionally with its own money locked
  3// against the claim.
  4//
  5// # What it is for
  6//
  7// It is a sybil gate, and it exists because the apps around it do not have
  8// one. A realm that mints a point per distinct replier is farmed by two
  9// addresses replying to each other; a realm that counts one vote per address
 10// is farmed by holding a hundred addresses. Neither can fix that alone,
 11// because neither knows anything about the people behind the addresses.
 12//
 13// This package knows one thing: who was willing to say, on chain and under
 14// their own name, that an address is a person they stand behind. A realm asks
 15// [Graph.IsTrusted] and gates on the answer. That is the whole product, and
 16// every other read here exists to make that one legible.
 17//
 18// # The model
 19//
 20//	Graph   every vouch, in both directions, plus the refund ledger
 21//	Vouch   a directed statement: from, for, reason, bond, heights
 22//
 23// A vouch is directed and at most one exists per ordered pair. A second
 24// [Graph.Record] from the same address to the same target UPDATES the reason
 25// and ADDS to the bond rather than counting twice, which is what makes the
 26// score a count of people instead of a count of transactions. An address
 27// cannot vouch for itself.
 28//
 29// # The bond
 30//
 31// A vouch may lock coins. The engine only counts them: it takes the amount as
 32// an argument, tracks who posted how much on whom, and on [Graph.Revoke] moves
 33// that amount into a withdrawal ledger the voucher pulls from. It moves
 34// nothing itself. The realm holding the coins performs the transfer AFTER
 35// [Graph.Withdraw] has zeroed the credit, which is the ordering the
 36// pull-payment pattern demands.
 37//
 38// # No slashing, and why that is the hard part
 39//
 40// A bond here is value at risk only in the sense that it is illiquid: it can
 41// be withdrawn by revoking, and nothing can take it away. That is deliberate
 42// for a v0, and the missing half is not the accounting.
 43//
 44// Slashing needs an arbiter: somebody has to decide that a vouch was a lie.
 45// Every candidate is a design question with teeth. A DAO vote is a popularity
 46// contest against whoever is unpopular this month. A challenge market pays
 47// whoever is loudest and turns the graph into a griefing surface. An oracle is
 48// one key that can confiscate anyone's money. Shipping any of them by default
 49// would be shipping the wrong one, so this version ships the part that is
 50// uncontroversial: who said what, who put money behind it, and the gate that
 51// reads it.
 52//
 53// # Reasons are attacker-controlled markdown
 54//
 55// A reason is free text. [ValidReason] bounds it to [MaxReasonLen] bytes on
 56// one line and refuses control characters, but everything inside that is
 57// allowed and must be escaped where it is shown: ui.Inline in prose, ui.Cell
 58// in a table cell.
 59package vouch
 60
 61import (
 62	"chain/markdown"
 63	"errors"
 64	"strconv"
 65	"strings"
 66
 67	"gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/addrset/v1"
 68	"gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/kit/num/v0"
 69	"gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/kit/tally/v0"
 70	"gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/md/v0"
 71	"gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/x/daily/pullpayment/v0"
 72)
 73
 74// MaxReasonLen is the longest reason accepted, in bytes. Long enough to say
 75// how you know somebody, short enough that one call cannot lock an unbounded
 76// storage deposit the realm's deployer is paying for.
 77const MaxReasonLen = 200
 78
 79const maxInt64 = int64(9223372036854775807)
 80
 81// The errors a caller can get back. A p/ returns them; the realm decides to
 82// abort.
 83var (
 84	ErrSelfVouch   = errors.New("vouch: an address cannot vouch for itself")
 85	ErrBadReason   = errors.New("vouch: reason is empty, too long, multi-line, or has control characters")
 86	ErrBadBond     = errors.New("vouch: a bond cannot be negative")
 87	ErrNoVouch     = errors.New("vouch: no such vouch")
 88	ErrNothingOwed = errors.New("vouch: nothing to withdraw")
 89	ErrOverflow    = errors.New("vouch: bond would overflow")
 90)
 91
 92// Vouch is one directed statement of trust.
 93type Vouch struct {
 94	From   address
 95	For    address
 96	Reason string
 97
 98	// Bond is the amount locked on this vouch, in the realm's denom. It is
 99	// the sum of every bond sent with this pair, since a repeated vouch
100	// adds to it rather than replacing it.
101	Bond int64
102
103	// At is the height the vouch was first made, UpdatedAt the height it
104	// last changed. They are equal until the voucher restates it.
105	At        int64
106	UpdatedAt int64
107}
108
109// Graph is the whole web of trust: every vouch, both directions of every
110// edge, what is bonded on whom, and what revoking owes back to whom.
111type Graph struct {
112	edges    map[string]*Vouch       // "from|for" -> the vouch
113	inbound  map[string]*addrset.Set // target -> who vouches for them
114	outbound map[string]*addrset.Set // voucher -> who they vouch for
115	bonded   map[string]int64        // target -> total bonded on them
116
117	// people is every address with at least one inbound vouch, sorted, so
118	// a listing never has to iterate a map to build rendered output.
119	people addrset.Set
120
121	refunds     *pullpayment.Ledger
122	count       int
123	totalBonded int64
124}
125
126// NewGraph returns an empty graph.
127func NewGraph() *Graph {
128	return &Graph{
129		edges:    map[string]*Vouch{},
130		inbound:  map[string]*addrset.Set{},
131		outbound: map[string]*addrset.Set{},
132		bonded:   map[string]int64{},
133		refunds:  pullpayment.New(),
134	}
135}
136
137// Record writes from's vouch for target and reports whether it replaced one
138// that already existed.
139//
140// A repeated vouch is an update and not a second voice: the reason is
141// replaced, the bond is added to the one already posted, and the score does
142// not move. bond may be zero, which is the ordinary case.
143func (g *Graph) Record(from, target address, reason string, bond, at int64) (updated bool, err error) {
144	if from == target {
145		return false, ErrSelfVouch
146	}
147	if !ValidReason(reason) {
148		return false, ErrBadReason
149	}
150	if bond < 0 {
151		return false, ErrBadBond
152	}
153	tk := target.String()
154	if g.bonded[tk] > maxInt64-bond || g.totalBonded > maxInt64-bond {
155		return false, ErrOverflow
156	}
157
158	if v, ok := g.edges[key(from, target)]; ok {
159		if v.Bond > maxInt64-bond {
160			return false, ErrOverflow
161		}
162		v.Reason = reason
163		v.Bond += bond
164		v.UpdatedAt = at
165		g.bonded[tk] += bond
166		g.totalBonded += bond
167		return true, nil
168	}
169
170	g.edges[key(from, target)] = &Vouch{
171		From:      from,
172		For:       target,
173		Reason:    reason,
174		Bond:      bond,
175		At:        at,
176		UpdatedAt: at,
177	}
178	g.set(g.inbound, tk).Add(from)
179	g.set(g.outbound, from.String()).Add(target)
180	g.people.Add(target)
181	g.bonded[tk] += bond
182	g.totalBonded += bond
183	g.count++
184	return false, nil
185}
186
187// Revoke removes from's vouch for target and credits the bond back to from,
188// returning the amount credited.
189//
190// The coins are not sent here and this package never holds any: the credit
191// waits in the refund ledger until from calls [Graph.Withdraw].
192func (g *Graph) Revoke(from, target address) (refund int64, err error) {
193	v, ok := g.edges[key(from, target)]
194	if !ok {
195		return 0, ErrNoVouch
196	}
197	// Credit first, because it is the only step that can fail. Nothing is
198	// mutated until the refund is certain, so a full ledger leaves the
199	// graph exactly as it was rather than half revoked.
200	if v.Bond > 0 {
201		if err := g.refunds.Credit(from.String(), v.Bond); err != nil {
202			return 0, err
203		}
204	}
205
206	delete(g.edges, key(from, target))
207	tk, fk := target.String(), from.String()
208
209	if in, ok := g.inbound[tk]; ok {
210		in.Remove(from)
211		if in.Size() == 0 {
212			delete(g.inbound, tk)
213			g.people.Remove(target)
214		}
215	}
216	if out, ok := g.outbound[fk]; ok {
217		out.Remove(target)
218		if out.Size() == 0 {
219			delete(g.outbound, fk)
220		}
221	}
222
223	g.bonded[tk] -= v.Bond
224	if g.bonded[tk] == 0 {
225		delete(g.bonded, tk)
226	}
227	g.totalBonded -= v.Bond
228	g.count--
229	return v.Bond, nil
230}
231
232// Withdraw zeroes what the graph owes who and returns it, so the realm can
233// send exactly that much.
234//
235// The credit is gone from the ledger before this returns, which is what makes
236// a reentrant call find nothing: the realm transfers after, never before.
237func (g *Graph) Withdraw(who address) (int64, error) {
238	amount, err := g.refunds.Withdraw(who.String())
239	if err != nil {
240		return 0, ErrNothingOwed
241	}
242	return amount, nil
243}
244
245// Get returns one vouch.
246func (g *Graph) Get(from, target address) (*Vouch, bool) {
247	v, ok := g.edges[key(from, target)]
248	return v, ok
249}
250
251// ScoreOf is how many distinct addresses vouch for addr.
252//
253// It counts people and not statements: restating a vouch does not raise it,
254// and revoking lowers it.
255func (g *Graph) ScoreOf(addr address) int {
256	if in, ok := g.inbound[addr.String()]; ok {
257		return in.Size()
258	}
259	return 0
260}
261
262// BondedFor is the total locked on addr by everyone vouching for them.
263func (g *Graph) BondedFor(addr address) int64 { return g.bonded[addr.String()] }
264
265// IsTrusted reports whether addr is vouched for by at least min distinct
266// addresses. It is the gate another realm calls, and the reason this package
267// exists.
268//
269// A min below one is raised to one. A gate that lets everybody through is a
270// bug at the call site rather than an answer worth returning, and silently
271// agreeing with it is how a sybil check ships disabled.
272//
273// What it cannot tell you is whether those vouchers are distinct PEOPLE. Two
274// addresses vouching for each other both reach a score of one for the price of
275// two transactions, which is why [Graph.Mutual] is exported and why a gate
276// that matters should ask for more than one.
277func (g *Graph) IsTrusted(addr address, min int) bool {
278	if min < 1 {
279		min = 1
280	}
281	return g.ScoreOf(addr) >= min
282}
283
284// VouchedBy is every address that vouches for addr, sorted.
285//
286// Sorted and not chronological: the set is the storage, the order is a total
287// order on the address, and two identical calls therefore render identically.
288func (g *Graph) VouchedBy(addr address) []address {
289	return collect(g.inbound[addr.String()])
290}
291
292// VouchesOf is every address addr vouches for, sorted. It is the other
293// direction of [Graph.VouchedBy].
294func (g *Graph) VouchesOf(addr address) []address {
295	return collect(g.outbound[addr.String()])
296}
297
298// Mutual reports whether a and b vouch for each other.
299//
300// A mutual pair is the cheapest sybil shape there is, so this is here to be
301// discounted by a caller that cares, not as a badge.
302func (g *Graph) Mutual(a, b address) bool {
303	if a == b {
304		return false
305	}
306	_, there := g.edges[key(a, b)]
307	_, back := g.edges[key(b, a)]
308	return there && back
309}
310
311// ReasonFrom is what from wrote about target, or the empty string when there
312// is no such vouch. It is raw caller text: escape it where it is shown.
313func (g *Graph) ReasonFrom(from, target address) string {
314	if v, ok := g.edges[key(from, target)]; ok {
315		return v.Reason
316	}
317	return ""
318}
319
320// BondFrom is what from locked on target, or zero.
321func (g *Graph) BondFrom(from, target address) int64 {
322	if v, ok := g.edges[key(from, target)]; ok {
323		return v.Bond
324	}
325	return 0
326}
327
328// Count is how many vouches exist, across everybody.
329func (g *Graph) Count() int { return g.count }
330
331// People is how many addresses have at least one vouch for them.
332func (g *Graph) People() int { return g.people.Size() }
333
334// Owed is what revoking has credited to addr and nobody has withdrawn yet.
335func (g *Graph) Owed(addr address) int64 { return g.refunds.Balance(addr.String()) }
336
337// TotalOwed is every unwithdrawn refund. The realm must hold at least
338// TotalOwed plus [Graph.TotalBonded] to be solvent.
339func (g *Graph) TotalOwed() int64 { return g.refunds.TotalOwed() }
340
341// TotalBonded is everything locked on every vouch that still stands.
342func (g *Graph) TotalBonded() int64 { return g.totalBonded }
343
344// Ranked is one row of [Graph.Leaderboard].
345type Ranked struct {
346	Addr   address
347	Score  int
348	Bonded int64
349}
350
351// Leaderboard is the most vouched for addresses, highest score first, ties
352// broken by address so the order is total and a Render never reshuffles.
353//
354// limit at or below zero returns nothing.
355func (g *Graph) Leaderboard(limit int) []Ranked {
356	if limit <= 0 || g.people.Size() == 0 {
357		return nil
358	}
359	entries := make([]tally.Entry, 0, g.people.Size())
360	g.people.IterateByOffset(0, g.people.Size(), func(a address) bool {
361		entries = append(entries, tally.Entry{Key: a.String(), Score: int64(g.ScoreOf(a))})
362		return false
363	})
364	top := tally.Top(entries, limit)
365
366	out := make([]Ranked, 0, len(top))
367	for _, e := range top {
368		a := address(e.Key)
369		out = append(out, Ranked{Addr: a, Score: int(e.Score), Bonded: g.bonded[e.Key]})
370	}
371	return out
372}
373
374// ValidReason reports whether reason can be stored: non-empty after the
375// render's own stripping and trimming,
376// within [MaxReasonLen], on one line, and free of control characters.
377//
378// One line is a deliberate bound rather than a rendering workaround. A reason
379// is shown in a table cell beside the address it is about, and a writer who
380// needs a second paragraph is writing something other than a reason.
381//
382// Control characters are refused rather than stripped because the text is
383// shown back to whoever wrote it, and silently rewriting what somebody said is
384// worse than telling them it was refused. Everything else is allowed and
385// escaped at render time, since a validator and an escaper protect against
386// different mistakes.
387func ValidReason(reason string) bool {
388	if len(reason) > MaxReasonLen || strings.TrimSpace(markdown.StripBidiAndZeroWidth(reason)) == "" {
389		return false
390	}
391	for i := 0; i < len(reason); i++ {
392		if c := reason[i]; c < 0x20 || c == 0x7f {
393			return false
394		}
395	}
396	return true
397}
398
399// Badge is the one-line trust mark, for a realm that wants to show what the
400// gate it just called was reading.
401//
402// It takes the numbers rather than the graph because the realm holding the
403// graph is the only one that can read it: everybody else has the two integers
404// from a cross-realm call and needs nothing more to render them.
405func Badge(realmPath string, addr address, score int, bonded int64) string {
406	if score == 0 {
407		return md.Link("not vouched for", AddrURL(realmPath, addr))
408	}
409	word := " vouchers"
410	if score == 1 {
411		word = " voucher"
412	}
413	out := "\U0001F91D " + md.Link(strconv.Itoa(score)+word, AddrURL(realmPath, addr))
414	if bonded > 0 {
415		out += " · " + num.GNOTf(bonded) + " bonded"
416	}
417	return out
418}
419
420// AddrURL is the gnoweb path of one address's page on the hosting realm.
421func AddrURL(realmPath string, addr address) string {
422	return RealmURL(realmPath) + ":addr/" + addr.String()
423}
424
425// RealmURL is the gnoweb path of a realm given as a package path.
426//
427// The chain domain is the first element of a package path and a gnoweb path is
428// the rest of it, so this is a prefix strip and not a hostname this package
429// has to know.
430func RealmURL(realmPath string) string {
431	if i := strings.Index(realmPath, "/"); i >= 0 {
432		return realmPath[i:]
433	}
434	return "/" + realmPath
435}
436
437// key is the storage key of one directed edge. Addresses are fixed-length
438// bech32, so the separator is belt and braces rather than load-bearing.
439func key(from, target address) string { return from.String() + "|" + target.String() }
440
441// set returns the address set stored under k, creating it on first use.
442func (g *Graph) set(m map[string]*addrset.Set, k string) *addrset.Set {
443	if s, ok := m[k]; ok {
444		return s
445	}
446	s := &addrset.Set{}
447	m[k] = s
448	return s
449}
450
451// collect reads a set out in sorted order.
452func collect(s *addrset.Set) []address {
453	if s == nil || s.Size() == 0 {
454		return nil
455	}
456	out := make([]address, 0, s.Size())
457	s.IterateByOffset(0, s.Size(), func(a address) bool {
458		out = append(out, a)
459		return false
460	})
461	return out
462}