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.

jury.gno

10.02 Kb · 341 lines
  1// Package jury is a commit-reveal adversarial review protocol for disputes
  2// over agent work.
  3//
  4// The hard part of an agent economy is not paying agents — it is deciding
  5// what happens when an agent's output is disputed. This realm runs a small
  6// jury: a fixed panel of reviewers each *commit* to a hidden verdict, then
  7// *reveal* it. Commit-reveal means no juror can copy another's vote or be
  8// swayed after seeing the tally; every verdict is locked in blind.
  9//
 10// The subject of a case is an opaque string — a receipt sequence, a claim
 11// id, an off-chain artifact hash — so this realm composes with the receipt
 12// and gnomem demos without depending on them.
 13//
 14// Bonding and slashing (a juror stakes coins, loses them for provably bad
 15// verdicts) are the natural economic layer on top; this demo shows the
 16// mechanism without the money.
 17package jury
 18
 19import (
 20	"chain"
 21	"chain/runtime"
 22	"strings"
 23
 24	"gno.land/p/nym-moultest123/agents/commit/v0"
 25	"gno.land/p/nym-moultest123/kit/ui/v0"
 26	"gno.land/p/nt/ufmt/v0"
 27)
 28
 29// Phase is a case's position in the commit-reveal lifecycle.
 30type Phase string
 31
 32const (
 33	PhaseCommit Phase = "commit"
 34	PhaseReveal Phase = "reveal"
 35	PhaseClosed Phase = "closed"
 36)
 37
 38// Juror is one panelist and their (eventually revealed) verdict.
 39type Juror struct {
 40	Addr       address
 41	Commitment string // commit.Verdict(verdict, salt)
 42	Committed  bool
 43	Revealed   bool
 44	Verdict    bool // meaningful once Revealed
 45	Height     int64
 46}
 47
 48// Case is a single dispute under review by a fixed panel.
 49type Case struct {
 50	ID       uint64
 51	Subject  string // opaque reference to the disputed thing
 52	Opener   address
 53	Phase    Phase
 54	Jurors   []*Juror
 55	OpenedAt int64
 56	Outcome  string // "upheld" | "rejected" | "tie" once closed
 57	Yes      int
 58	No       int
 59}
 60
 61var cases []*Case // index 0 => ID 1
 62
 63// The bounds on what a wallet may open. OpenCase is callable by anyone, so
 64// without them the case list and every panel grow without limit, and so does
 65// the index page that renders them.
 66const (
 67	MaxCases    = 1000
 68	MaxJurors   = 25
 69	MaxSubject  = 200 // bytes
 70	IndexRecent = 50  // cases the index lists, newest first
 71
 72	MaxCommitment = 128 // bytes; commit.Verdict returns 64
 73)
 74
 75// OpenCase starts a dispute with a fixed panel of jurors, given as
 76// comma-separated addresses. An odd panel size avoids ties; duplicates and
 77// invalid addresses are rejected.
 78//
 79// The panel is a string, not a []address, because MsgCall carries every
 80// argument as a string and the VM decodes no slice but []byte: a []address
 81// parameter made this function unreachable from any wallet.
 82func OpenCase(cur realm, subject, jurorsCSV string) uint64 {
 83	opener := caller(cur)
 84	// Checked after sanitizing: a subject of only zero-width runes is not empty
 85	// as bytes, and renders as nothing.
 86	assert(strings.TrimSpace(ui.Inline(subject)) != "", "empty subject")
 87	assert(len(subject) <= MaxSubject, "subject too long")
 88	assert(len(cases) < MaxCases, "the jury is full")
 89	jurors := parseJurors(jurorsCSV)
 90	assert(len(jurors) >= 1, "need at least one juror")
 91	assert(len(jurors) <= MaxJurors, "panel too large")
 92
 93	seen := make(map[string]bool)
 94	panel := make([]*Juror, 0, len(jurors))
 95	for _, j := range jurors {
 96		key := j.String()
 97		assert(!seen[key], "duplicate juror: "+key)
 98		seen[key] = true
 99		panel = append(panel, &Juror{Addr: j})
100	}
101
102	id := uint64(len(cases)) + 1
103	cases = append(cases, &Case{
104		ID:       id,
105		Subject:  subject,
106		Opener:   opener,
107		Phase:    PhaseCommit,
108		Jurors:   panel,
109		OpenedAt: runtime.ChainHeight(),
110	})
111	chain.Emit("CaseOpened", "id", ufmt.Sprintf("%d", id), "jurors", ufmt.Sprintf("%d", len(panel)))
112	return id
113}
114
115// Commit locks in a juror's hidden verdict. commitment must equal
116// commit.Verdict(verdict, salt) — computed off-chain. When the last juror
117// commits, the case advances to the reveal phase automatically.
118func Commit(cur realm, caseID uint64, commitment string) {
119	who := caller(cur)
120	c := mustCase(caseID)
121	assert(c.Phase == PhaseCommit, "not in commit phase")
122	assert(commitment != "", "empty commitment")
123	// commit.Verdict is a hex sha256; anything longer is not one, and would be
124	// stored and compared on every later Commit in the case.
125	assert(len(commitment) <= MaxCommitment, "commitment too long")
126	j := mustJuror(c, who)
127	assert(!j.Committed, "already committed")
128	// Every commitment is public through Get, so without this a juror could
129	// commit a copy of another's hash, then reveal the same (verdict, salt)
130	// once the first juror's reveal puts it in a transaction. Refusing a
131	// repeat makes a copied commitment unrevealable.
132	for _, other := range c.Jurors {
133		assert(!other.Committed || other.Commitment != commitment, "commitment already used in this case")
134	}
135	j.Commitment = commitment
136	j.Committed = true
137	j.Height = runtime.ChainHeight()
138
139	if allCommitted(c) {
140		c.Phase = PhaseReveal
141		chain.Emit("CasePhase", "id", ufmt.Sprintf("%d", caseID), "phase", string(PhaseReveal))
142	}
143}
144
145// Reveal opens a juror's verdict. The (verdict, salt) must reproduce the
146// commitment made earlier, or the reveal is rejected. When the last juror
147// reveals, the case closes and the majority outcome is recorded.
148func Reveal(cur realm, caseID uint64, verdict bool, salt string) {
149	who := caller(cur)
150	c := mustCase(caseID)
151	assert(c.Phase == PhaseReveal, "not in reveal phase")
152	j := mustJuror(c, who)
153	assert(j.Committed && !j.Revealed, "nothing to reveal")
154	assert(commit.Verdict(verdict, salt) == j.Commitment, "reveal does not match commitment")
155
156	j.Revealed = true
157	j.Verdict = verdict
158	if verdict {
159		c.Yes++
160	} else {
161		c.No++
162	}
163	chain.Emit("VerdictRevealed", "id", ufmt.Sprintf("%d", caseID), "verdict", boolStr(verdict))
164
165	if allRevealed(c) {
166		closeCase(c)
167	}
168}
169
170// ---- read-only API ----
171
172// Get returns a copy of a case (jurors flattened separately via Jury).
173func Get(caseID uint64) Case { return *mustCase(caseID) }
174
175// Outcome returns the recorded outcome, or "" if the case is still open.
176func Outcome(caseID uint64) string { return mustCase(caseID).Outcome }
177
178// Count returns the number of cases.
179func Count() int { return len(cases) }
180
181// ---- internal ----
182
183func closeCase(c *Case) {
184	switch {
185	case c.Yes > c.No:
186		c.Outcome = "upheld"
187	case c.No > c.Yes:
188		c.Outcome = "rejected"
189	default:
190		c.Outcome = "tie"
191	}
192	c.Phase = PhaseClosed
193	chain.Emit("CaseClosed", "id", ufmt.Sprintf("%d", c.ID), "outcome", c.Outcome)
194}
195
196func allCommitted(c *Case) bool {
197	for _, j := range c.Jurors {
198		if !j.Committed {
199			return false
200		}
201	}
202	return true
203}
204
205func allRevealed(c *Case) bool {
206	for _, j := range c.Jurors {
207		if !j.Revealed {
208			return false
209		}
210	}
211	return true
212}
213
214func mustCase(id uint64) *Case {
215	assert(id >= 1 && id <= uint64(len(cases)), "unknown case")
216	return cases[id-1]
217}
218
219func mustJuror(c *Case, addr address) *Juror {
220	for _, j := range c.Jurors {
221		if j.Addr == addr {
222			return j
223		}
224	}
225	panic("caller is not on this jury")
226}
227
228func boolStr(b bool) string {
229	if b {
230		return "yes"
231	}
232	return "no"
233}
234
235func assert(cond bool, msg string) {
236	if !cond {
237		panic(msg)
238	}
239}
240
241// Render shows all cases, or one case's panel + tally at :<id>.
242func Render(path string) string {
243	if path == "" {
244		return renderIndex()
245	}
246	return renderCase(parseID(path))
247}
248
249func renderIndex() string {
250	var sb strings.Builder
251	sb.WriteString("# Agent Jury\n\n")
252	sb.WriteString("_Who checks the agents?_ Blind commit-reveal review by a fixed panel.\n\n")
253	if len(cases) == 0 {
254		sb.WriteString("_No cases yet._\n")
255		return sb.String()
256	}
257	if len(cases) > IndexRecent {
258		sb.WriteString(ufmt.Sprintf("The %d most recent of %d cases, newest first.\n\n", IndexRecent, len(cases)))
259	}
260	sb.WriteString("| # | Subject | Phase | ✅ | ❌ | Outcome |\n")
261	sb.WriteString("|---|---|---|---|---|---|\n")
262	// Newest first, and only the last IndexRecent: the page has to render at
263	// MaxCases too.
264	for i := len(cases) - 1; i >= 0 && i >= len(cases)-IndexRecent; i-- {
265		c := cases[i]
266		out := c.Outcome
267		if out == "" {
268			out = "—"
269		}
270		sb.WriteString(ufmt.Sprintf("| [%d](/r/nym-moultest123/agents/jury/v0:%d) | %s | %s | %d | %d | %s |\n",
271			c.ID, c.ID, ui.Cell(c.Subject), string(c.Phase), c.Yes, c.No, out))
272	}
273	return sb.String()
274}
275
276func renderCase(id uint64) string {
277	c := mustCase(id)
278	var sb strings.Builder
279	sb.WriteString(ufmt.Sprintf("# Case #%d\n\n", c.ID))
280	sb.WriteString(ufmt.Sprintf("- **Subject:** %s\n", ui.Inline(c.Subject)))
281	sb.WriteString(ufmt.Sprintf("- **Phase:** %s\n", string(c.Phase)))
282	sb.WriteString(ufmt.Sprintf("- **Opened by:** %s at height %d\n", short(c.Opener), c.OpenedAt))
283	if c.Outcome != "" {
284		sb.WriteString(ufmt.Sprintf("- **Outcome:** **%s** (%d ✅ / %d ❌)\n", c.Outcome, c.Yes, c.No))
285	}
286	sb.WriteString("\n## Panel\n\n")
287	sb.WriteString("| Juror | Committed | Revealed | Verdict | Dissent |\n|---|---|---|---|---|\n")
288	majority := c.Yes >= c.No // for dissent marking once closed
289	for _, j := range c.Jurors {
290		verdict, dissent := "—", ""
291		if j.Revealed {
292			verdict = boolStr(j.Verdict)
293			if c.Phase == PhaseClosed && c.Outcome != "tie" && j.Verdict != majority {
294				dissent = "⚠"
295			}
296		}
297		sb.WriteString(ufmt.Sprintf("| %s | %t | %t | %s | %s |\n",
298			short(j.Addr), j.Committed, j.Revealed, verdict, dissent))
299	}
300	return sb.String()
301}
302
303func parseID(s string) uint64 {
304	var n uint64
305	for i := 0; i < len(s); i++ {
306		ch := s[i]
307		assert(ch >= '0' && ch <= '9', "invalid case path: "+s)
308		n = n*10 + uint64(ch-'0')
309	}
310	return n
311}
312
313func short(a address) string {
314	s := a.String()
315	if len(s) <= 12 {
316		return s
317	}
318	return s[:8] + "…" + s[len(s)-4:]
319}
320
321// parseJurors splits a comma-separated panel, trimming spaces around each
322// address and refusing an empty or invalid one.
323func parseJurors(csv string) []address {
324	var out []address
325	for _, part := range strings.Split(csv, ",") {
326		a := address(strings.TrimSpace(part))
327		assert(a.IsValid(), "invalid juror address: "+string(a))
328		out = append(out, a)
329	}
330	return out
331}
332
333// caller is the address that crossed into this realm, after checking that the
334// realm token is this frame's own. Every entry point reads it first, before
335// any other work.
336func caller(cur realm) address {
337	if !cur.IsCurrent() {
338		panic("jury: spoofed realm")
339	}
340	return cur.Previous().Address()
341}