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

vouch.gno

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